everestmx/nova-belongs-to-many-field
A Laravel Nova field that adds support for editing belongsToMany relationships directly in Nova resources. It provides a select-based interface for attaching and detaching related models, simplifying many-to-many relationship management in your admin panel.
Install and confirm compatibility first. This package is a community Nova field, not first-party. Before writing any code:
composer require everestmx/nova-belongs-to-many-field
Then immediately open vendor/everestmx/nova-belongs-to-many-field/composer.json and check the require block for the Nova major version (laravel/nova: ^4.0 vs ^5.0). A field package that targets Nova 4 will usually install on Nova 5 and then fail at render time with a missing Vue component — verify before building a screen around it. If there are no releases/tags, review the default branch diff against the last commit date; treat unmaintained as likely and plan to vendor-patch.
Where to look first. Three files tell you everything:
src/*.php — the Field subclass. Note the exact namespace and the $component string; you'll need both in your resource.resources/js/components/Field.vue (or components/IndexField.vue + DetailField.vue) — reveals whether the field supports index display, search, and in-place attach/detach, or is detail-only.use statement verbatim rather than guessing the namespace.First use case. The canonical fit is a pivot-backed relation where users need to manage membership inline: User ↔ Role, Post ↔ Tag, Product ↔ Category. Pick a resource where the relation already has belongsToMany defined on the Eloquent model, then:
use Everestmx\NovaBelongsToManyField\BelongsToMany; // confirm against README
public function fields(NovaRequest $request)
{
return [
// ...
BelongsToMany::make('Roles'),
];
}
Mount the detail page and confirm the related records load. If the field renders but is empty, the problem is almost always the relation name/label mismatch, not the field.
Pivot attributes are explicit, not automatic. Because the field mirrors belongsToMany, anything stored on the pivot must be declared twice — once on the relationship and once on the field:
// Model
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class)
->withPivot('assigned_at', 'assigned_by')
->withTimestamps();
}
// Resource
BelongsToMany::make('Roles')
->fields(function () {
return [
DateTime::make('Assigned At')->rules('required'),
BelongsTo::make('Assigned By', 'assignedBy', User::class)->nullable(),
];
}),
Omitting either side silently discards the input — no exception, no validation error.
Large candidate sets need searchable relatables. Detail-view attach UIs that load every related model will time out once the target table passes a few thousand rows. Wire up the search endpoint on the related resource (searchable() on its ID/title fields) and constrain the candidate query:
BelongsToMany::make('Roles')
->searchable()
->relatableQueryUsing(fn ($request, $query) => $query->where('active', true));
If the package exposes no relatableQueryUsing, apply the constraint inside the related resource's indexQuery / a global scope instead — same effect, coarser control.
Guard the attach/detach surface with policies. Attach and detach are separate authorization actions. Define attachRole, detachRole, and attachAnyRole on the parent policy; without them Nova falls back to update, which is usually too permissive for pivot management:
public function attachRole(User $user, Account $account): bool
{
return $user->hasPermissionTo('accounts.manage-roles');
}
Detail-only is the safe default. Many community BTM fields implement a rich detail component and a read-only index count. Explicitly set ->onlyOnDetail() or ->showOnIndex() so the field's behavior is documented in code rather than discovered in QA.
Pair with BelongsToMany on the inverse side when both directions need editing, and use ->dependsOn() / ->readonly() for computed pivot columns (e.g. "assigned by" set server-side in a ResourceStoreController override or a BelongsToMany pivot event).
Test at the HTTP layer, not the field layer. Field packages frequently break on Nova upgrades without breaking PHP. Add a Nova request test that posts to the attach endpoint and asserts a pivot row exists; that single test will catch 80% of upgrade regressions.
Nova version drift is the dominant risk. With zero dependents and no release cadence, assume the package is pinned to whatever Nova version existed at last commit. Pin it in composer.json, and if you need to upgrade Nova, budget a day to either fork the Vue component or replace the field with a first-party BelongsTo + custom tool.
Attach-on-create doesn't work. The parent record has no ID until after insert, so pivot rows cannot be written during creationRules/rules. Expect the "create" form to either hide the field or drop silently; if you need pre-assignment, save the parent in a creating/created model event or a Nova\Fields\BelongsToMany-style two-step flow (create → detail → attach).
sync vs attach semantics. If the field syncs, submitting a form that omits a previously attached record will detach it. Multi-tenant or partial-update screens can lose pivot data this way. Check the PHP side for sync/syncWithoutDetaching and understand which one you're getting.
Duplicate pivot rows. Without a composite unique index on (parent_id, related_id), race conditions or repeated submits create duplicates that render twice. Add the index in a migration — the field won't do it for you.
Global scopes leak into candidate lists. relatableQueryUsing and indexQuery both run through the related model, so soft deletes and tenant scopes apply. If a record "disappears" from the attach list, check withoutGlobalScopes behavior and the model's SoftDeletes trait.
MorphToMany is not BelongsToMany. If your relation is polymorphic, this field is very likely the wrong tool — verify before assuming it works because the API looks identical.
Debugging a blank or broken render:
php artisan nova:publish # if the package ships assets
ls vendor/everestmx/nova-belongs-to-many-field/resources/js
Open the browser console — a Failed to resolve component error means the $component name in the field class doesn't match the built JS bundle (common when a package ships unbundled source). Run npm run dev in the package directory or copy the .vue into your app's nova-components and override $component.
Extension point when the package is too thin. Override resolveUsing() for read-only display formatting, subclass the field and change $component to your own built Vue component, or ->displayUsing(fn ($value) => ...) for index-cell rendering. Subclassing is safer than patching vendor/ and cheap to maintain.
Document the decision. Given zero dependents and no score, write a short ADR in the repo explaining why this package was chosen over a hand-rolled BelongsTo + pivot resource pair, and what the fallback is. Future maintainers will thank you.
How can I help you explore Laravel packages today?