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 a Category of Tasks. The relationship between User and Task is defined by both team_id and category_id.
// User model
public function tasks()
{
return $this->hasMany(Task::class, ['team_id', 'category_id'], ['team_id', 'category_id']);
}
// Task model (inverse relationship)
public function user()
{
return $this->belongsTo(User::class, ['team_id', 'category_id'], ['team_id', 'category_id']);
}
Usage:
$user = User::find(1);
$tasks = $user->tasks; // Works with eager loading
$task = Task::find(1);
$user = $task->user; // Inverse works too
// User has many Tasks (composite keys)
public function tasks()
{
return $this->hasMany(Task::class, ['user_id', 'team_id'], ['id', 'team_id']);
}
// Task belongs to User (inverse)
public function user()
{
return $this->belongsTo(User::class, ['user_id', 'team_id'], ['id', 'team_id']);
}
// User belongs to many Projects (composite pivot keys)
public function projects()
{
return $this->belongsToMany(
Project::class,
'user_project',
['user_team_id', 'user_department_id'], // Foreign keys for User
['project_team_id', 'project_department_id'], // Foreign keys for Project
['team_id', 'department_id'], // Local keys for User
['team_id', 'department_id'] // Local keys for Project
);
}
// Attach projects to a user (composite tuples)
$user->projects()->attach([
['EU', 2], // [team_id, department_id]
['US', 1],
]);
// Sync with attributes
$user->projects()->sync([
json_encode(['EU', 2]) => ['role' => 'reviewer'],
json_encode(['US', 1]) => ['role' => 'lead'],
], ['note' => 'bulk applied']);
class TenantUser extends Model
{
use Compoships;
protected $primaryKey = 'id';
public $incrementing = false;
protected $compositeKey = ['id', 'tenant_id']; // Opt into composite handling
}
Factories:
Use ComposhipsFactory trait for testing:
use Awobaz\Compoships\Database\Eloquent\Factories\ComposhipsFactory;
class UserFactory extends Factory { use ComposhipsFactory; }
Queueable Collections: Wrap collections for queue jobs:
use Awobaz\Compoships\Queue\QueueableCompositeCollection;
$queueable = QueueableCompositeCollection::for($users);
Custom Pivot Models:
Extend Awobaz\Compoships\Database\Eloquent\Relations\Pivot for composite-key pivots:
class UserProjectPivot extends Pivot { ... }
Eager Loading: Works out-of-the-box:
User::with('tasks')->get();
Query Scoping:
Use whereHas with composite keys:
User::whereHas('tasks', function ($query) {
$query->where('category_id', 'web');
})->get();
Null Composite Keys: Relationships with all null composite keys are unsupported. Ensure at least one column has a non-null value.
Trait Consistency:
Both models in a relationship must use Compoships (either via trait or base class). Mixing will break eager loading.
Composite Primary Key Validation:
If $compositeKey doesn’t include $primaryKey, the first save()/delete() throws InvalidUsageException.
Queueable Collections:
Raw Collection with composite keys won’t restore from queues. Always wrap with QueueableCompositeCollection.
Nullable Columns in WHERE:
Nullable composite-key columns use IS NULL instead of = NULL to avoid SQL false positives.
Route Model Binding:
Still uses $primaryKey (scalar). Composite keys are not used for binding (e.g., /users/{id}).
Custom setKeysForSaveQuery Overrides:
Call parent::setKeysForSaveQuery($query) first to retain composite key handling.
Eager Loading Issues:
Verify both models use Compoships. Check for typos in column names.
Attachment Errors:
Ensure attach()/sync() tuples match pivot table columns. Use json_encode() for map keys:
$user->projects()->attach([json_encode(['EU', 2]) => ['role' => 'admin']]);
Composite Key Queries: Log the generated SQL to debug WHERE clauses:
\DB::enableQueryLog();
$user->save();
dd(\DB::getQueryLog());
Queue Restoration Failures:
Confirm QueueableCompositeCollection is used. Check for mixed-class collections.
Soft Deletes:
Composite keys work with SoftDeletes, but ensure $compositeKey includes the soft-deletable columns.
Custom Pivot Logic:
Extend Awobaz\Compoships\Database\Eloquent\Relations\Pivot for custom pivot behavior.
Composite Key Macros: Add macros to models for reusable composite logic:
class User extends Model {
public function scopeInTeam($query, $teamId) {
return $query->where('team_id', $teamId);
}
}
Query Builder Extensions:
Extend Awobaz\Compoships\Database\Query\Builder for custom composite queries.
Event Listeners:
Listen for compoships.* events (e.g., compoships.attaching) for custom logic during attachments.
Composite Key Validation:
Override validateCompositeKey() in your model to enforce custom rules:
protected function validateCompositeKey()
{
if (empty($this->team_id) && empty($this->department_id)) {
throw new \Exception('Composite key cannot be empty');
}
}
Indexing: Ensure composite foreign keys are indexed for performance:
ALTER TABLE tasks ADD INDEX idx_user_team_category (user_id, team_id, category_id);
Batch Operations:
Use chunk() for large attach()/sync() operations to avoid memory issues:
$user->projects()->sync($projects->chunk(100));
Avoid N+1: Always eager load composite relationships:
User::with('tasks')->get(); // Not User::find(1)->tasks
How can I help you explore Laravel packages today?