thybag/bonus-laravel-relations
Extends Laravel's Eloquent ORM with bonus relationship types beyond the built-in hasOne, hasMany, and belongsTo helpers, making it easy to define richer model associations and reuse common relation patterns without writing custom query code.
thybag/bonus-laravel-relations is a small utility package that adds extra Eloquent relationship types on top of Laravel's built-ins. Treat it as a modelling convenience, not a framework: there is no admin UI, no config to publish, and typically no service provider to register — you pull in a trait per model and get new relation methods.
Minimum steps:
composer require thybag/bonus-laravel-relationscomposer.json so a future composer update on a mixed-version monorepo does not surprise you.src/ to see the trait names, since that is what you will use in your models.->join() chains in a controller. Replace that with the package's relation method, then ->with() it and delete the join.Trait-per-model, declared like any other relation. The package's traits expose relation methods that return standard Relation instances. That is the important property: because they return Relation, everything downstream — with(), withCount(), whereHas(), load(), loadMissing() — behaves as you already expect. Do not build a parallel abstraction around them.
Compose, don't replace. Keep the natural hasMany/belongsTo chain on the model and add the bonus relation alongside it as a named convenience:
class Team extends Model
{
use HasBonusRelations; // trait name per README
public function owner(): BelongsTo { ... }
public function activeMembers(): /* bonus relation */
{
return $this->bonusRelation(...)->where('active', true);
}
}
Scoping inside the relation method is usually better than scoping at every call site — it keeps withCount('activeMembers') honest across the app.
Eager-load deliberately. Use these relations with with() for list endpoints, and withCount() when the UI just shows a badge. Because the underlying SQL is a join or exists subquery rather than N queries, eager loading is where the package actually pays for itself; using it lazily gives you the worst of both worlds.
Tests are the spec. These relations are easy to get subtly wrong (wrong key, wrong intermediate model). Write a feature test per relation asserting a known fixture set, plus one assertDatabaseCount-style check against a hand-written query builder version of the same result. When you upgrade the package, that test is your regression net.
Verify generated SQL at least once. In a tinker session or a test, dump ->toSql() for each bonus relation you adopt and paste the query into your DB client. Five minutes here catches almost every surprise (duplicate rows from a missing distinct, unexpected whereNull, extra joins from global scopes).
Version pinning is the real story of 0.6.0. The only change is Laravel 13 support. That is good news — the package is maintained — but it also means the Laravel constraint widened, so re-run your suite after upgrading rather than assuming relation internals are untouched.
Key and table inference is the usual failure point. Bonus relations have more "guesses" to make than a plain hasMany (foreign key, owner key, intermediate model, sometimes a pivot). If a relation returns empty unexpectedly, pass the keys explicitly before you start debugging anywhere else.
Inverse relations are not automatic. Laravel cannot infer the inverse of a non-standard relation. Provide your own belongsTo back-reference and note it in the model docblock.
Global scopes and soft deletes propagate through intermediate models. A SoftDeletes model in the middle of the chain will change your result set, and withTrashed() on the endpoint will not necessarily reach it. Assert both directions in a test.
whereHas on these relations compiles to correlated subqueries. Fine for a filter on a page of 50; a problem in an export over 500k rows. For big reports, fall back to a query-builder join and keep the relation for the UI path.
Duplicates in joins. Multi-hop joins can multiply rows when the intermediate table has several matches. If you see the same model twice in a collection, that is a signal to add distinct or rethink the relation — do not paper over it with ->unique() in the controller.
Debugging recipe: DB::listen() in a local service provider, log slow queries over ~50ms, and confirm your eager loads are actually batching. If a page issues one query per row, you forgot with() somewhere — Model::preventLazyLoading() in non-production is the cheapest guard.
Extension points are thin by design. There is no config file to hook into. If you need different behaviour, subclass or copy the relation class into app/ — the package is small enough that forking a single trait is a legitimate option and cheaper than fighting an abstraction.
How can I help you explore Laravel packages today?