ardalanamini/laravel-custom-relation
A Laravel package that lets you define custom Eloquent relationships beyond the built-in hasMany, belongsTo, and morphTo types. Build relations with your own query logic and constraints, keeping complex model associations flexible and readable.
composer require ardalanamini/laravel-custom-relation. Laravel package auto-discovery handles registration; there is no service provider to register, no config to publish, and no migrations in the 0.1.x line. Pin it exactly in composer.json ("ardalanamini/laravel-custom-relation": "0.1.5") — 0.x semver permits breaking changes on a minor bump.HasCustomRelations-style). That trait is the whole API surface; everything else is Eloquent's standard Relation contract. Skim the trait source before writing anything — at this version it is small enough to read end-to-end in ten minutes and that is the fastest way to learn what eager loading and existence queries actually do and don't support.hasMany/belongsTo genuinely cannot express: posts by the same author within a rolling window, a join on a composite or non-standard key, a tenant-scoped association, or a relation that needs a whereExists constraint. Do not start with something hasMany already does — you will pay the indirection cost and learn nothing.public function recentPostsByAuthor()
{
return $this->customRelation(...); // return the relation object, not a Builder
}
$user->recentPostsByAuthor for lazy access, $user->recentPostsByAuthor()->toSql() for the generated SQL, then User::with('recentPostsByAuthor')->get() for eager loading. If any of those three fail, stop and pick a different abstraction — everything downstream depends on them.->get() or a Builder breaks eager loading and, more subtly, breaks the whereHas()/has() paths that Eloquent builds by reflecting on the method's return type. If your method signature or return value looks like a builder, with() will quietly do something other than what you expect.whenLoaded(), loadMissing(), withCount() — these should all work or all visibly fail, not silently half-work. Test whenLoaded() explicitly in a resource test; a relation that returns rows correctly but does not populate the relations array will silently omit itself from your JSON and nobody will notice for weeks.User::with(['recentPostsByAuthor' => fn ($q) => $q->where('published', true)]) is the pattern your team already knows. Verifying that constrained eager loading actually applies here is a day-one spike item (see Gotchas); if it doesn't, write your own scope and apply it inside the relation closure rather than asking every caller to remember.->distinct() or a subquery aggregate) or many, then write a test that asserts the exact count. This is the single most common way these relations go wrong in production and it does not throw.DB::enableQueryLog() or assertQueryCount) and a toSql() snapshot. The snapshot is also your onboarding artifact — paste the expected SQL into the relation's docblock so the next developer knows the intended shape without reading the closure.Concerns/HasBillingRelations.php) rather than scattering them across fat models. When the 0.1.x API changes — and it will — you edit one file per domain instead of grepping the entire app/Models tree.composer.json edit from a first-time contributor. Nothing functional changed, but a composer.json-only release can still break your install by narrowing a framework or PHP constraint. Run composer update --dry-run with the new tag before bumping, and check the diff of that file specifically — not the release title.with(['rel' => fn ($q) => $q->where(...)]) requires the package to route constraints through the relation's addConstraints()/addEagerConstraints() in the way Eloquent expects. If constraints appear to be dropped, confirm against toSql() inside the eager-load closure. If it's genuinely unsupported, fall back to loading unconstrained and filtering in PHP, and open an issue — do not paper over it with a scope inside the closure and assume it behaves the same.whereHas() and withCount() are separate code paths from with(). A relation can work perfectly for lazy access and eager loading while returning wrong aggregates in withCount(). If you plan to use existence queries or counts, write a test for each before the relation reaches a dashboard or a paginated endpoint that filters on it.->toSql() and ->getBindings() on $model->relationMethod(), and enable DB::listen() / the query log for the eager-load path. Because the package builds the query lazily, calling toSql() on something you partially chained can show you a different query than the one that actually runs.DB::raw("... {$id} ...") tends to appear. Use bindings, or better, express the condition with whereColumn()/on() so there is no string to inject into. Grep your PR for DB::raw before merging anything that touches a relation closure.->distinct() or move the aggregate into a subquery, and assert the count in a test. This is the highest-severity failure mode of the whole package because it produces plausible-but-wrong results rather than an exception.whereExists subquery correlation differs across drivers. Run the feature test in CI against your production engine.composer.json. Subscribe to releases and schedule the upgrade alongside, not after, your next Laravel major bump; you want to discover an Eloquent-internals incompatibility in a branch, not mid-upgrade.Relation internals. Keep the surface area you use small — a handful of relation methods behind one trait per domain — so that if the package stalls, reimplementing it in-house is a two-day job rather than a project. Assign a named owner who is comfortable reading Relation/HasOneOrMany source; without one, this becomes orphaned knowledge the moment they change teams.vendor/. Keep the closure logic in your domain trait so a package API change is a single-file migration.How can I help you explore Laravel packages today?