Extending Laravel: Crafting Custom Eloquent Relations with Pure PHP

Overview: Relations as Pure PHP Code

Eloquent relations often feel like magic, but they are essentially a sophisticated wrapper around a query builder and custom PHP logic. By source-diving into the

framework, we can see that relations are objects that follow a specific lifecycle. Understanding this lifecycle allows us to break free from standard belongsTo or hasMany patterns and implement unconventional data connections, such as comma-separated ID strings, geospatial proximity, or even AI-generated predictions. When you realize that the database side is optional, the flexibility of Eloquent expands significantly.

Prerequisites

To get the most out of this tutorial, you should be comfortable with the following:

  • PHP 8.x: Proficiency with classes, anonymous functions, and array manipulation.
  • Laravel Eloquent: Basic knowledge of standard relationship types and model structures.
  • Collections: Familiarity with Laravel's collect, map, and flatmap methods.

Key Libraries & Tools

  • Eloquent: The core database abstraction layer in Laravel.
  • Faker: A PHP library used to generate fake data for testing or prototyping.
  • Prism: A package used for integrating AI/LLM responses into PHP applications.
  • MySQL Spatial Functions: Database-level functions used for calculating distances between coordinates.

Code Walkthrough: The Relationship Lifecycle

Eager loading in Laravel happens in three distinct phases: adding constraints, fetching results, and matching results to parents. To create a custom relation, you must implement the addEagerConstraint and match methods.

1. Handling Comma-Separated IDs

Suppose you have a legacy database where IDs are stored as a string: "1,2,3". You can create a HasSpeakers class that extends Relation. In the addEagerConstraint method, we extract these IDs into a clean array to query the database efficiently.

public function addEagerConstraints(array $models)
{
    $ids = collect($models)
        ->map(fn ($model) => explode(',', $model->speaker_ids))
        ->flatten()
        ->unique();

    $this->query->whereIn('id', $ids);
}

2. The Matching Phase

After the database returns the related models, the match method associates them back to the original parent models. This is where we manually populate the relationship attribute on each model.

public function match(array $models, Collection $results, $relation)
{
    $resultsById = $results->keyBy('id');

    foreach ($models as $model) {
        $ids = explode(',', $model->speaker_ids);
        $matches = collect($ids)->map(fn ($id) => $resultsById->get($id))->filter();
        $model->setRelation($relation, $matches);
    }

    return $models;
}

Syntax Notes: Custom Instantiation

You don't have to use Laravel's built-in methods to define a relation. Instead of calling $this->hasMany(), you can return your custom class directly within your model. This gives you full control over the constructor and any extra parameters, like distance thresholds for geospatial queries or custom prompts for AI logic.

Practical Examples

  • Geospatial Proximity: Using the ST_Distance_Sphere function in MySQL to find all "Ice Cream Shops" within 500 meters of an "Event" coordinates.
  • AI Predictions: Using
    Prism
    to predict future events based on historical data, instantiating transient models on the fly.
  • Fake Data Generation: Using
    Faker
    to simulate relationships for a UI prototype when the backend table doesn't exist yet.

Tips & Gotchas

  • Stick to Conventions: 99% of the time, standard relations are better for maintenance and team clarity. Only use custom relations when data is shaped irregularly.
  • Cloning Models: When matching results, consider cloning models if the same related record belongs to multiple parents to avoid state issues.
  • Performance: Heavy PHP processing inside the match loop can slow down large collections. Keep logic efficient.
3 min read