Allow nested arg resolvers to run before parent model is saved#2777
Merged
Conversation
…DEFINITION Allow explicit `@belongsTo` and `@hasOne` directives on input fields to control nested mutation resolution. `@belongsTo` implements `PreSaveArgResolver` so the FK is set on the parent model before save, avoiding NOT NULL constraint violations. 🤖 Generated with Claude Code
There was a problem hiding this comment.
Pull request overview
Adds a new resolver marker interface to control nested mutation execution ordering, enabling relation directives to run nested resolvers either before or after the parent model is persisted. This is aimed at preventing NOT NULL FK violations for BelongsTo nested mutations by setting the FK prior to saving.
Changes:
- Introduce
PreSaveArgResolverto mark nested arg resolvers that must run before the parent model is saved. - Update
ResolveNestedto split nested resolvers into pre-save vs post-save execution phases. - Extend
@belongsToand@hasOneto supportINPUT_FIELD_DEFINITIONand act as nested arg resolvers.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/Support/Contracts/PreSaveArgResolver.php | Adds a marker interface to indicate pre-save nested arg resolution. |
| src/Execution/Arguments/ResolveNested.php | Partitions nested resolvers into pre-save/post-save and changes execution order accordingly. |
| src/Schema/Directives/BelongsToDirective.php | Enables @belongsTo on input fields and implements pre-save nested resolver behavior. |
| src/Schema/Directives/HasOneDirective.php | Enables @hasOne on input fields and implements nested resolver behavior (post-save). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Pre-save resolvers now run after $previous (model is identified and saved), then trigger an additional save if they modified the model. This makes @belongsTo on INPUT_FIELD_DEFINITION work correctly for all mutation flows (create, update, upsert), not just create. Also rejects MorphTo relations in @belongsTo with a clear assertion message, since MorphTo requires separate handling via @morphTo. 🤖 Generated with Claude Code
🤖 Generated with Claude Code
…ions PreSaveArgResolver args (like @belongsTo on INPUT_FIELD_DEFINITION) must set foreign keys before the model is persisted. Previously ResolveNested ran them after SaveModel and compensated with a second save — breaking NOT NULL FK columns on INSERT. Now SaveModel extracts and invokes PreSaveArgResolver args alongside implicit BelongsTo, so both paths are equivalent: FK is set before the single save. #2777 (comment) #2777 (comment) 🤖 Generated with Claude Code
…ng, test coverage - Skip null-valued PreSaveArgResolver args to prevent TypeError - Add descriptive messages to all assert() calls - Wrap HasOneDirective with ResolveNested for deep nesting support - Add test for mismatched field name using relation: argument - Add CHANGELOG entry - Document PreSaveArgResolver ordering contract 🤖 Generated with Claude Code
nestedArgResolvers → postSaveArgResolvers preSaveResolvers → preSaveArgResolvers 🤖 Generated with Claude Code
🤖 Generated with Claude Code
🤖 Generated with Claude Code
…esolver Revert directive changes and their ~890 lines of tests — that use case isn't needed. The PreSaveArgResolver interface and its integration into ArgPartitioner/SaveModel remain as the extension point for custom directives. Add unit + integration tests proving PreSaveArgResolver works with a custom @uppercase directive. 🤖 Generated with Claude Code
Proves the real use case: setting a FK via BelongsTo before save. Uses `relation:` arg to avoid collision with implicit relation detection. 🤖 Generated with Claude Code
Removes the null guard so directives can handle disassociation (e.g. $relation->associate(null)). Adds a test proving null flows through to the resolver. 🤖 Generated with Claude Code
…detection Extract preSaveArgResolvers before relationMethods in SaveModel so a directive-annotated field is never captured by name-based relation detection. Adds a test using field name "user" matching the model method directly (no relation: arg needed). 🤖 Generated with Claude Code
Both are documented user extension points for custom directives. 🤖 Generated with Claude Code
Documents the interface design for dynamic pre/post-save timing in mutation ArgResolvers, replacing the static PreSaveArgResolver marker interface. 🤖 Generated with Claude Code
🤖 Generated with Claude Code
Extracts relationName() and uses it to dynamically decide timing: BelongsTo/MorphTo → pre-save, everything else → post-save. 🤖 Generated with Claude Code
nestedArgResolvers now excludes resolvers where runBeforeSave() is true, passing them through to SaveModel which invokes them before $model->save(). 🤖 Generated with Claude Code
🤖 Generated with Claude Code
🤖 Generated with Claude Code
spawnia
commented
Jun 30, 2026
NestDirective no longer contains resolution logic — it only serves as a marker for argument partitioning. ResolveNested detects it and recurses into the nested ArgumentSet directly. 🤖 Generated with Claude Code
…e nesting 🤖 Generated with Claude Code
ResolveNested carried $this->previous (SaveModel) into @nest recursion, causing $model->save() to fire at every nesting level. Fix by creating a new ResolveNested(null, ...) for @nest traversal. Also makes liftPreSaveResolversFromNest recursive so pre-save resolvers at any @nest depth are lifted to the outermost level, simplifies the partition predicate into separate guard clauses, merges the two foreach loops, and documents nullable $value for SaveAwareArgResolver. 🤖 Generated with Claude Code
# Conflicts: # src/GlobalId/Base64GlobalId.php # src/Schema/Directives/BaseDirective.php
…into pre-save-arg-resolver
- Unit test for nestedArgResolversWithoutPreSave with non-Model root - DB assertion in testUpsertBelongsToWithNullValue proving no User created 🤖 Generated with Claude Code
🤖 Generated with Claude Code
DRY up the instanceof + runBeforeSave check that was duplicated across liftPreSaveResolversFromNest and preSaveNestedArgResolvers. Replace @phpstan-ignore-next-line with a proper assert() in ResolveNested, and use early-continue to reduce nesting. 🤖 Generated with Claude Code
The schema-time validation catches misuse early (scalars, lists) instead of producing confusing runtime errors. Also handles nullable @nest receiving null at runtime gracefully by skipping resolution. #2777 (comment) #2777 (comment) 🤖 Generated with Claude Code
🤖 Generated with Claude Code
🤖 Generated with Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
SaveAwareArgResolverinterface (@api) that lets ArgResolver directives declare whether they run before or after the parent model is saved during mutation execution.Motivation
For BelongsTo relations, the FK lives on the parent model and must be set before
$model->save().The existing implicit detection in
SaveModelhandles this for known relation methods, but there was no way for custom directives to participate in the pre-save phase.SaveAwareArgResolveris the extension point: any directive implementing it can control its execution timing relative to$model->save().Design decisions
NestDirectivebecomes a pure marker —ResolveNestedhandles recursion directly, creating a fresh instance withoutSaveModelto prevent double-saves at each nesting level. Pre-save resolvers inside@nestare recursively lifted to the top level byArgPartitioner::liftPreSaveResolversFromNest().@nestnow validates at schema build time that it is used on a non-list input object type (viaArgManipulatorandInputFieldManipulator).runBeforeSave(Model $model)is class-based, not instance-dependent — the model may not yet be hydrated when this is called. Implementations must base the decision on the model class and its relations.When
runBeforeSave()returns true,__invoke()may receive null as$valueif the client sends null for a nullable input field.Documentation
SaveAwareArgResolversection indocs/master/custom-directives/input-value-directives.mdwith usage example@nestdirective reference notes the type constraintArgResolverinterface PHPDoc improved with description and@seereference🤖 Generated with Claude Code