dillingham/nova-attach-many
A Laravel Nova field for attaching many related models to a BelongsToMany relationship in one modal. Search, paginate, and create records inline, then attach or detach them without leaving the form. Includes customizable display, authorization, and pivot support.
Match the package major to the Nova major — this is the whole install. Nova 4 gets ^2.0; Nova 3 is stuck on the frozen 1.x line. The 1.x → 2.x jump was a Vue 3 / asset-pipeline rewrite, not an additive release, so a wrong constraint silently produces a blank field.
composer require dillingham/nova-attach-many:^2.0
php artisan nova:publish # field ships its own compiled assets
php artisan optimize:clear
No provider registration. If the field renders empty or unstyled after install, an unpublished/stale asset bundle is the first thing to check, not the field itself.
Read these four things before writing any code, in this order:
public function tags(): BelongsToMany. The string passed to AttachMany::make() must resolve to a BelongsToMany. HasMany/MorphMany will render something that looks plausible and does nothing useful.public static $searchable = [...]. A blank title or missing $searchable is the usual cause of "empty modal" and "search doesn't filter."attachAny<Relationship> / attach<Relationship>, related viewAny.First use case — tags on a post. Start scoped to the detail view:
use Dillingham\NovaAttachMany\AttachMany;
public function fields(Request $request)
{
return [
ID::make()->sortable(),
Text::make('Title'),
AttachMany::make('Tags')->onlyOnDetail(),
];
}
Open a record's detail page: searchable modal, attach, inline detach. That's the entire happy path — roughly ten minutes from composer require to a working screen.
Smoke test before you build on it:
assertDatabaseHas / assertDatabaseMissing, never by eyeballing the UI.post_tag has a unique index.Use the fluent config whenever the relationship isn't a naive global list. The field exposes the resource class, the display attribute, and a query constraint — reach for all three:
AttachMany::make('Members')
->resource(MemberResource::class)
->title('full_name')
->query(fn ($query) => $query
->where('team_id', request()->user()->team_id)
->where('active', true));
->resource() matters whenever the relationship's studly name doesn't map to the resource Nova would guess (Members → MemberResource versus a namespaced Admin\MemberResource). Setting it explicitly kills a whole class of silent breakage after a refactor or namespace move.
Wrap it in a builder once you have more than one or two resources. This is where the package actually pays off — otherwise every resource drifts on scoping and title attribute:
// app/Nova/Concerns/AttachesMany.php
protected function attachMany(string $relationship, string $resource, ?Closure $query = null): AttachMany
{
return AttachMany::make($relationship)
->resource($resource)
->title('name')
->query($query ?? fn ($q) => $q->where('tenant_id', Nova::user()->tenant_id));
}
Now tenant scoping is defined once and auditable in one file, which is the difference between "consistent admin pattern" and "seven resources with seven different filters."
Default to ->onlyOnDetail(). Putting a relationship field in the create/update form means every submit participates in relationship syncing. Read the 2.0.0 source's fill/resolve behavior and verify an untouched field does not clear existing pivots before you place it on a form. That's the most damaging failure mode available here, and a major-version bump is exactly when such behavior can shift.
Layer visibility and run-permission on top of policy authorization. Nova's relationship authorization already gates the field, but you can shape the UI further:
AttachMany::make('Roles')
->canSee(fn ($request) => $request->user()->can('update', $this->resource))
->canRun(fn ($request) => $request->user()->isAdmin());
If the modal lists nothing, the policy is the first suspect — not the package.
Migrate from the native field one resource at a time. Don't rip out BelongsToMany::make() everywhere at once. Keep the native field where you need pivot columns or per-row pivot UI; swap in AttachMany only for "attach/detach existing records, nothing else." You get a side-by-side comparison and a one-line rollback.
Pivot defaults belong in the model layer. The field attaches with no pivot payload. If attached rows need created_by, added_at, or source, set them in the pivot schema or a belongsToMany observer — not in Nova.
Test the write path, not the Vue component:
use RefreshDatabase;
public function test_admin_can_attach_tag(): void
{
$this->actingAs(Nova::user($admin))
->postJson("/nova-api/posts/{$post->id}/update", [
'tags' => [$tag->id],
'tags_update' => true,
])
->assertOk();
$this->assertDatabaseHas('post_tag', ['post_id' => $post->id, 'tag_id' => $tag->id]);
}
Add a second test asserting a user without policy rights attaches nothing. The write path is where a major-version rewrite is most likely to regress, and it's the cheapest thing to pin down.
Keep the option set small at scale. Pair the field with a real $searchable list on the related resource plus a database index on the title column. ->query() runs server-side, so it's your lever for modal responsiveness on large tables.
Pin the major, never float. ^2.0 for Nova 4, ^1.0 for Nova 3. A one-line release note covers a breaking rewrite. Don't let composer update pick it up unattended — treat any major bump as its own ticket with a regression pass on every resource using the field.
Debug an empty modal in this exact order: relationship returns BelongsToMany → related resource resolves → title attribute is non-empty → $searchable is defined → parent policy allows attachAny<Relationship>. Then open the network tab and look at the search request: does it return rows at all? That sequence saves the most time of anything in this document.
Authorization failures are silent. A missing or false attachAnyTags/attachTags on the parent policy yields an empty modal or a no-op save. Same for viewAny on the related resource. This gets reported as a package bug far more often than it is one.
Duplicate pivot rows are your problem, not the package's. There is no uniqueness enforcement in the UI. Add $table->unique(['post_id', 'tag_id']) in the pivot migration. Without it, repeated attach/detach cycles and concurrent editors accrete duplicates that corrupt counts and break later sync() assumptions.
No pivot attribute UI. If the relation needs role, position, approved_at, or validation on the pivot, this isn't the tool — use the native BelongsToMany field with pivot fields, or a dedicated pivot resource. Bolting pivot data on via model events works until someone needs to edit it.
Hide it from the index. This is a detail-view interaction, not a column. Add ->hideFromIndex() (or ->onlyOnDetail()) so the resource index doesn't render a non-sortable stub.
Bulk attach is N inserts with no built-in cap. Selecting a few hundred records produces a burst of pivot inserts and lock contention. Validate selection size via Nova validation on the relationship field, keep bulk operations to a sane ceiling, and wrap scripted attaches in DB::transaction().
Detach is immediate and there's no undo. Put a warning in the field's help() text when the relationship carries real meaning, and rely on audit logging (activity-log tooling) rather than on the package.
A blank field after a Nova upgrade is the expected failure, not a surprise. That's the classic symptom of a Nova bump against an unmaintained field. Mitigate with a post-deploy smoke check on one representative resource, and keep the fallback (BelongsToMany::make()) documented and cheap to swap in.
Upgrade checklist for any Nova major: read the diff for constraint and asset-registration changes → composer dump-autoload → php artisan nova:publish → php artisan optimize:clear → hard-refresh the browser → re-verify authorization and pivot behavior on staging with production-scale data. Rollback is a pin revert plus asset republish — no data operation, which is what keeps this dependency low-risk.
Plan for the maintenance reality. Single maintainer, MIT, no SLA, one release (2022-07-25) after a multi-year gap. Budget roughly a day per quarter plus a defined block on every Nova upgrade, record the validated Nova minor in your dependency inventory, and name a fork owner before adoption spreads past one or two resources.
How can I help you explore Laravel packages today?