awobaz/compoships
Adds composite-key relationship support to Laravel Eloquent. Define hasOne/hasMany/belongsTo relations matching two or more columns so eager loading works with legacy or third‑party schemas, using a custom base Model or Compoships trait.
Installation:
composer require awobaz/compoships
Model Integration: Choose either extending the base model:
use Awobaz\Compoships\Database\Eloquent\Model;
class User extends Model { ... }
or using the trait:
use Awobaz\Compoships\Compoships;
class User { use Compoships; ... }
Define Composite Relationship:
public function tasks()
{
return $this->hasMany(Task::class, ['team_id', 'category_id'], ['team_id', 'category_id']);
}
Test Eager Loading:
$users = User::with('tasks')->get();
Scenario: A user belongs to a team and is responsible for tasks in specific categories. The relationship requires matching both team_id and category_id:
// User model
public function responsibleTasks()
{
return $this->hasMany(Task::class, ['team_id', 'category_id'], ['team_id', 'category_id']);
}
// Task model (inverse)
public function responsibleUser()
{
return $this->belongsTo(User::class, ['team_id', 'category_id'], ['team_id', 'category_id']);
}
Key Insight: Compoships resolves the "null foreign key" issue during eager loading by deferring the relationship query until the parent model is fully hydrated.
Composite Key Relationships:
$this->hasMany(RelatedModel::class, ['fk1', 'fk2'], ['local1', 'local2']);
$this->belongsTo(RelatedModel::class, ['fk1', 'fk2'], ['local1', 'local2']);
$this->belongsToMany(
RelatedModel::class,
'pivot_table',
['fk1_a', 'fk2_a'], // Foreign keys for Model A
['fk1_b', 'fk2_b'], // Foreign keys for Model B
['local1_a', 'local2_a'], // Local keys for Model A
['local1_b', 'local2_b'] // Local keys for Model B
);
Eager Loading with Constraints:
Use whereHas() with composite keys:
User::whereHas('tasks', function ($query) {
$query->where('status', 'pending')
->where('priority', 'high');
})->get();
Dynamic Composite Relationships: For polymorphic or dynamic relationships, use method closures:
public function getRelationship($type)
{
$keys = $this->getKeysForType($type);
return $this->hasMany(RelatedModel::class, $keys, $keys);
}
Pivot Table Operations:
$user->projects()->attach([['team_id', 'dept_id']]);
$user->projects()->detach([['team_id', 'dept_id']]);
$user->projects()->sync([
json_encode(['EU', 2]) => ['role' => 'reviewer'],
]);
Factories: Extend ComposhipsFactory for composite relationships in testing:
use Awobaz\Compoships\Database\Eloquent\Factories\ComposhipsFactory;
class UserFactory extends Factory { use ComposhipsFactory; }
Then use has() with composite keys:
User::factory()->has(Task::factory()->count(3), 'responsibleTasks');
API Resources: Handle composite relationships in JSON responses:
public function toArray($request)
{
return [
'id' => $this->id,
'tasks' => $this->tasks->map(fn ($task) => [
'id' => $task->id,
'team_id' => $task->team_id,
'category_id' => $task->category_id,
]),
];
}
Query Scopes: Create reusable composite scopes:
public function scopeAssignedToTeam($query, $teamId, $categoryId)
{
return $query->whereHas('responsibleUser', function ($q) use ($teamId, $categoryId) {
$q->where('team_id', $teamId)
->where('category_id', $categoryId);
});
}
Custom Pivot Models: For belongsToMany, extend Awobaz\Compoships\Database\Eloquent\Relations\Pivot:
class UserProjectPivot extends Pivot { ... }
Null Composite Keys:
public function tasks()
{
return $this->hasMany(Task::class, ['team_id', 'category_id'])
->where(function ($query) {
$query->whereNotNull('team_id')
->orWhereNotNull('category_id');
});
}
Eager Loading Quirks:
with() constraints or load constraints separately:
// Correct: Load constraints after eager loading
$users = User::with('tasks')->get();
$users->loadMissing(['tasks' => fn ($query) => $query->where('status', 'pending')]);
Many-to-Many Pivot Issues:
Awobaz\Compoships\Database\Eloquent\Relations\Pivot, not Laravel’s base Pivot.use Awobaz\Compoships\Database\Eloquent\Relations\Pivot;
class UserProjectPivot extends Pivot { ... }
Table Prefix Mismatches:
snake_) aren’t applied to composite keys.snake_tasks instead of tasks).JSON-Encoded Keys in attach():
attach() throw InvalidUsageException.$key = json_encode(['EU', 2]);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \InvalidArgumentException("Invalid composite key format");
}
Query Logging: Enable Laravel’s query logging to inspect composite key queries:
\DB::enableQueryLog();
$users = User::with('tasks')->get();
dd(\DB::getQueryLog());
Composite Key Validation:
Use dump() to verify composite keys during development:
public function tasks()
{
$keys = ['team_id', 'category_id'];
dump($this->{$keys[0]}, $this->{$keys[1]}); // Debug local keys
return $this->hasMany(Task::class, $keys, $keys);
}
Factory Debugging: For factories, ensure composite relationships are mocked correctly:
User::factory()
->has(Task::factory()->state([
'team_id' => 1,
'category_id' => 2,
])->count(2), 'responsibleTasks')
->create();
Custom Query Grammar:
Override Awobaz\Compoships\Database\Eloquent\Builder to modify composite key behavior:
class CustomBuilder extends \Awobaz\Compoships\Database\Eloquent\Builder
{
protected function whereKey($query, $operator, $values, $boolean = 'and')
{
// Custom logic for composite keys
}
}
Composite Key Serialization: Extend JSON serialization for composite keys in API responses:
public function toJson($options = 0)
{
return json_encode([
'id' => $this->id,
'composite_key' => [$this->team_id, $this->category_id],
]);
}
Dynamic Relationship Keys: Dynamically resolve composite keys based on runtime conditions:
public function dynamicRelationship()
{
$keys = $this->getDynamicKeys();
return $this->hasMany(RelatedModel::
How can I help you explore Laravel packages today?