webparking/nova-belongs-to-dependency
A Laravel Nova package that makes BelongsTo fields depend on other form fields, enabling cascading or filtered selects. Choose a parent value and the related BelongsTo options update accordingly, keeping Nova forms dynamic and consistent.
This package replaces Nova's native BelongsTo with a version that narrows its options based on another field on the same form. It is a single-purpose field: parent field changes → child field reloads. That's the whole product, and it's the reason it fits into a day of work rather than a sprint.
Install and pin
composer require webparking/nova-belongs-to-dependency:^2.0.1
Pin at ^2.0.1 deliberately. 2.0.1 is a patch that repairs component registration for the Index/Detail/Form views; on 2.0.0 you get blank columns and Failed to resolve component noise in the console. There is no feature difference between the two — don't go looking for one.
Nothing to publish in the normal case. The service provider registers the compiled assets with Nova::script() / Nova::style(). If a previous developer ran vendor:publish for this package, delete the published copies so the provider stays authoritative — otherwise you can end up loading a stale copy that contradicts the composer version.
Then the usual post-install ritual before you judge anything: php artisan view:clear && php artisan config:clear, hard-refresh, restart Octane/FrankenPHP workers if you run them.
First use case — the smallest thing that proves it works
On a Venue resource, make Country an ordinary searchable BelongsTo, then make State the dependency field:
use Laravel\Nova\Fields\BelongsTo;
use Webparking\NovaBelongsToDependency\BelongsToDependency;
public function fields(NovaRequest $request): array
{
return [
BelongsTo::make('Country', 'country', Country::class)->searchable(),
BelongsToDependency::make('State', 'state', State::class)
->dependsOn('country'),
];
}
Create a venue: pick a country, watch the state list narrow. Edit that venue: the state list must populate from the persisted country with no user interaction. Those two checks are your smoke test for the rest of your life with this package.
Where to look, in order
make() plus one dependency method. Read it before copying snippets — confirm the exact method name and argument signature rather than trusting a blog post or a memory.src/BelongsToDependency.php. This is the file you fork if you ever need different query behaviour. Read it once and you'll understand every behaviour you'll later debug.2.0.1 fixed.FormField.vue, DetailField.vue, IndexField.vue). One read-through answers the question you'll ask repeatedly: "why is this empty in a modal?" Because it is driven entirely by sibling form state, and modals don't have it unless you put it there.Two-level cascade — 90% of usage. Parent plain, child dependent. Country → State, Category → Subcategory, Client → Project, Team → Member. No customization needed beyond the two field declarations.
Three-level chains. Chain the same pattern: city depends on state, state depends on country. Declaration order must be strictly parent → child → grandchild. Test the middle level changing specifically — the grandchild should clear and reload. If it doesn't, that's a genuine finding to raise upstream or fix in a fork, not something you're doing wrong.
Layer it on top of Nova's existing query hooks. The dependency filter composes with your relationship constraints, so relatableQueryUsing() still applies. Use this on purpose:
BelongsToDependency::make('Project', 'project', Project::class)
->dependsOn('client')
->relatableQueryUsing(fn (NovaRequest $request, Builder $query) =>
$query->where('archived', false)
)
->searchable(),
searchable() + a constrained query is the answer to large child tables: the dropdown becomes a server-side search over a small result set rather than a giant option list rendered into the page.
Treat the update form as the real acceptance test. On create, the child list can appear correct because the user drove the parent. On edit, the parent is pre-set and the child must resolve from the persisted value with zero interaction. If create works and edit is empty, your dependency argument doesn't match the parent field's attribute, or the parent field isn't in the current field set (onlyOnDetail(), different panel, hidden by a canSee).
Mirror the dependency in validation — always. The UI narrows the choice; it does not enforce it. A stale form post or a crafted request can persist a state from the wrong country.
'state' => [
'nullable',
Rule::exists('states', 'id')->where('country_id', $this->country?->id),
],
Decide nullable explicitly. "Clear the parent, leave the child" and partial saves both hit this rule.
Modals and actions need the parent present. Nova action fields() return only what you list. A modal with just the child field renders a full or empty option list because there is no sibling state to read. Options: include the parent in the modal's field set, use standalone mode, or precompute the value in PHP and skip the cascade in that context.
Index/detail are safe to show. After 2.0.1 the field resolves and displays its related model like a normal BelongsTo. Don't reflexively add ->onlyOnForms() — that removes the label along with the column.
Adopt a resource-level convention so the pattern is discoverable to the next developer who won't read the package source: parent and dependent fields adjacent in fields(), dependency argument named identically to the relationship key, one comment line above the cascade describing the chain.
Testing split. Feature-test the Nova API for store/update, asserting both valid and invalid pairings are handled (the invalid one should fail via your Rule::exists). The reload itself is client-side — HTTP tests won't touch it. Cover that with Dusk or a manual QA checklist, and add a smoke check that the form renders with no console errors after deploy. That last one is the cheapest regression signal you have for asset-registration breakage.
->dependsOn('country') pairs with BelongsTo::make('Country', 'country', ...). Name the column country_id in the same array and you get a silently empty dropdown with no error anywhere.fields(), and on the same form. Moving the parent into another panel or hiding it breaks the dependency in a way that looks exactly like a package bug — check your field set before you check the package.view:clear, config:clear, restart Octane/FrankenPHP, hard-refresh. "Component not resolving" is almost always cache, not code.relatableQueryUsing, global scopes) and usually ends the investigation immediately.withTrashed() or relax the scope inside relatableQueryUsing() wherever historical values must remain selectable.belongsTo chains only. BelongsToMany, MorphTo, non-Eloquent option sources, and heavy multi-tenant scoping are custom work — bending the package costs more than writing the field.->searchable() and make sure the foreign key you filter on is indexed.class MyDependentField extends BelongsToDependency and override the option-query building, then register your subclass in fields(). Keeps composer update painless.BelongsTo (loosen validation, widen the option query) so an incident isn't also a design meeting.How can I help you explore Laravel packages today?