digital-creative/custom-relationship-field
A Laravel Nova field for customizable relationship selection. Define how related records are searched and displayed, giving Nova resources flexible relationship inputs that fit naturally into forms while reducing the need for custom field code.
Confirm the gate before anything else: this is a Laravel Nova field. Without Nova in the project it does nothing. Check composer.json for the package's Nova constraint against your installed Nova major — the 2024 release line implies Nova 4, but verify rather than assume.
Pin the install. Zero dependents, a single visible maintainer, MIT, last release July 2024. That is a vendored utility, not a platform dependency:
composer require digital-creative/custom-relationship-field:^1.1.3
v1.1.3 is the floor, not a nice-to-have. It fixes a buildIndexQuery crash when selecting all BelongsToMany items — meaning the package hooks into Nova's index pipeline, not just its public field API. Anything earlier is a latent admin error.
Read three files, in this order, before writing resource code:
vendor/digital-creative/custom-relationship-field/src/ — the whole package is essentially one field class. In five minutes you can read the constructor signature and the methods that set the related resource, the options/query callback, display label, and search columns. Every method name in this assessment (->options(...)) follows documented usage — verify it against the source, because the API surface is small enough that guessing is inexcusable.BelongsToMany field source, side by side. You want to see precisely which parts of the native UX are reimplemented (search, select, attach) and which are skipped (persistence semantics).BelongsToMany::make(...).First use case — a throwaway resource, not a production one. A "Featured Products" curation on a Collection resource is the canonical shape: admins attach Products, but there is no collection_product pivot. Store IDs in a JSON column and let the field supply the select/search UX.
// app/Nova/Collection.php
use DigitalCreative\CustomRelationshipField\CustomRelationshipField;
public function fields(Request $request)
{
return [
ID::make()->sortable(),
Text::make('Name'),
CustomRelationshipField::make('Featured Products')
->options(fn () => Product::query()
->where('is_active', true)
->orderBy('name')
->pluck('name', 'id')
->all()),
];
}
Definition of working: search → select → save → re-open shows the identical set, and the stored value round-trips through the model cast. Prove that on the throwaway resource before an editor touches it.
1. JSON-cast column as the backing store. Standardize on this. The "relationship" becomes an ordinary attribute readable in factories, seeders, exports, and controllers with no Nova in the loop:
// Collection model
protected $casts = ['featured_product_ids' => 'array'];
2. Move option queries out of the resource. Never inline a fat closure in fields(). Put it on the model so Nova, seeders, and console commands share one source of truth:
// Product.php
public static function featuredOptions(): array
{
return static::query()
->where('is_active', true)
->orderBy('name')
->pluck('name', 'id')
->all();
}
Then ->options(Product::featuredOptions()). Highest-leverage single change you can make: it makes the field swappable for a native relationship later with zero consumer changes, and it makes the query testable without Nova.
3. Wrap it in a project-local field class. app/Nova/Fields/CuratedRelationship.php extending the package field, used everywhere. When Nova 5 lands, or you fork, you edit one file instead of grepping twenty resources. It is also the correct home for ->help('Curated list — not a database relationship.').
4. Authorization scoping lives inside your options closure. Nova policies on the owning resource do not constrain the options query. Multi-tenant and role scoping must be explicit:
->options(fn () => Product::query()
->where('tenant_id', auth()->user()->tenant_id) // not optional
->where('is_active', true)
->pluck('name', 'id')
->all())
Treat every ->options() closure as a place a data leak can hide, and review it as such in code review.
5. Treat it as write-through, not derived. If the field is editable, the saved value is the truth. Anything downstream — cache, search index, sitemap — needs a hook:
static::saved(function ($collection) {
if ($collection->wasChanged('featured_product_ids')) {
Cache::forget("collection:{$collection->id}:featured");
SearchIndex::reindex($collection);
}
});
6. Keep it out of your relationship API. Resolve explicitly in controllers and services:
$products = Product::whereIn('id', $collection->featured_product_ids ?? [])->get();
Note whereIn does not preserve order. If curation order matters — and for "featured" it usually does — store [{id, position}] or an ordered array and re-sort in PHP.
7. Roll out one resource at a time. Start with a low-risk surface (CMS collections, internal dashboards). Verify index browsing, search, sorting, filtering, and select-all against your exact Nova build. Then copy the pattern.
8. Memoize the options array per request if the field appears on list views, using once() (Laravel 11) or a static guard. Same closure invoked N times per page render is an easy N+1.
It is not a relationship, and Eloquent does not know it exists. No ->featuredProducts, no whereHas, no eager loading, no cascades, no foreign keys. Deleting a Product leaves a dangling ID in every JSON column referencing it. If that matters, schedule cleanup:
Collection::each(function ($c) {
$clean = array_values(array_intersect(
$c->featured_product_ids ?? [],
Product::pluck('id')->all()
));
if ($clean !== $c->featured_product_ids) {
$c->update(['featured_product_ids' => $clean]);
}
});
The v1.1.3 fix is a compatibility warning, not just a changelog line. It patches buildIndexQuery, confirming the package reaches into Nova's index-query pipeline. Every Nova minor bump is a potential break. Add to your upgrade checklist: branch → bump Nova → open every resource using the wrapper field → test select-all → merge.
Test select-all with realistic volume. It crashed once; now it succeeds, which only moves the question to cost. Select-all over thousands of records materializes a large ID set into a JSON column. Bound the options list (domain filter or a cap) so the UI cannot offer a set you cannot store or render.
Index views can go N+1. A field that resolves per row turns a page render into N queries. If the list view slows after adding it, look there first.
Verify assets land during deploy. If the package ships compiled frontend assets, confirm the publish step (vendor-documented or nova:publish) runs in CI. "Renders as a blank box" is almost always an asset/mix problem — check the browser console before debugging backend code.
Don't double-model. The day a real pivot table appears, delete the custom field in the same PR. Two sources of truth diverge on the first edit through the other path.
Debug by bypassing Nova. Call the options closure from php artisan tinker with dd(). Query right + UI wrong ⇒ Nova/asset issue. Query wrong ⇒ yours. That split resolves most sessions with a package this small.
Write it down as a vendored utility. 33 stars, 0 dependents, one maintainer, last release mid-2024. Pin the version, keep the one-file seam (Pattern 3), and expect to patch a Nova major yourself. Name an internal owner.
Leave a contract comment in the resource or model: what backs the field, that it is admin-only, and that the stored value is curated data — not something any API, export, or report reads. That comment is the difference between a planned migration and an incident six months out.
How can I help you explore Laravel packages today?