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 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, andflatmapmethods.
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_Spherefunction in MySQL to find all "Ice Cream Shops" within 500 meters of an "Event" coordinates. - AI Predictions: Using Prismto predict future events based on historical data, instantiating transient models on the fly.
- Fake Data Generation: Using Fakerto 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
matchloop can slow down large collections. Keep logic efficient.
