Skip to content

(feat) Create and save records through relationships - #378

Merged
techmahedy merged 4 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x
Oct 18, 2026
Merged

techmahedy merged 4 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x

Conversation

@techmahedy

@techmahedy techmahedy commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Pull Request Checklist

Q A
Branch? 4.x
Bug fix? yes
New feature? yes
Deprecations? no
Issues -
License MIT

Description

After this PR, you can now create and save related records through a relationship, and Doppar sets the foreign key for you:

$comment = $post->comments()->create(['body' => 'Nice post!']);   // post_id is set

$comment->post()->associate($otherPost);
$comment->save();

The problem

Doppar had good read-side relationship tools (embed, embedCount, present, whereLinked, pivot link and relate), but no way to write through a relationship. A relation is a query builder, so $post->comments()->create([...]) ran the plain builder create():

  • The foreign key was not set. The new comment had no post_id, so it was an orphan.
  • There was no createMany, no way to attach an existing model to a parent, and no way to point a child at a different parent without setting the key by hand.

What changed

linkOne and linkMany relations

Method What it does
create(array $attributes) Creates the record with the foreign key set from the parent. The parent's key always wins over a value in the attributes.
createMany(array $records) Creates several records and returns a Collection. Each record is a separate insert, so callers can wrap it in a transaction.
store(Model $related) Sets the foreign key on an existing or new model and saves it. It moves a record that belongs to another parent. Returns the model, or false if the save fails.
storeMany(iterable $models) store() for each model.
firstOrCreate(), updateOrCreate() These already existed on the builder. They now look only at the parent's own records, and create the missing one with the key set.

All writes go through the model's own save(), so hooks, watches and temporal snapshots run. ->withoutHook()->create(...) and ->withoutHook()->store(...) skip them.

bindTo relations

Method What it does
associate(Model|int|string $parent) Sets this model's foreign key to the parent's key. It does not save. Returns the model.
dissociate() Sets the foreign key to null. It does not save. Returns the model.

Rules that apply to all writes

  • The parent must be saved. Writing through the relation of a model with no key throws a LogicException.
  • The foreign key must be in the child's $creatable. If it is not, the call throws a LogicException that names the column and the model, and nothing is written. Without this check the insert would silently drop the foreign key and leave an orphan row. It also means a relation can never write a column the model has not opted into.
  • store() throws an InvalidArgumentException for a model of the wrong class.

Related fix: relations of an unsaved model match nothing

While checking the write rules I found an existing bug. A relation on a model with no key yet, such as (new Author())->articles, matched every row whose foreign key is NULL, because where(fk, '=', null) becomes fk IS NULL. So an unsaved model "owned" every orphan row. linkOne, linkMany and bindTo now match nothing when the key they point at is null. The write methods already refused an unsaved parent, so reads and writes now agree.

Supporting change

Model::isCreatable(string $attribute): bool is new and public. The relation classes use it for the check above.

Checklist

  • Tests have been added or updated
  • Documentation has been updated: Docs
  • Code follows the project coding standards
  • All tests pass locally

@techmahedy techmahedy added the feat new feature label Oct 17, 2026

@abdulmajidcse abdulmajidcse left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@techmahedy, Reviewed the full diff and CI (all checks green).

Blocking: BindTo::associate() does not check the parent's class.
store() throws InvalidArgumentException for a model of the wrong class, but associate() only reads $parent->{$this->ownerKey}. So $article->author()->associate(new Bio()) silently sets author_id to the bio's id, which is the kind of orphan write this PR is meant to prevent. Please add the same class check and a test for the wrong-class case. CI does not cover this path today.

Non-blocking:

  • Model::newRelationBuilder() now copies every non-static Builder property from $related::query() on each relation access. This is what makes query() overrides apply to relations, but it also adds an extra builder per access and copies any future property automatically. A short comment explaining why it is needed would help.
  • Relations on an unsaved model now match nothing, where they used to match NULL foreign keys. This is tested and documented, but it is a behavior change worth a release note.

Once the associate() check is in, this looks good to merge.

@techmahedy

Copy link
Copy Markdown
Member Author

@techmahedy, Reviewed the full diff and CI (all checks green).

Blocking: BindTo::associate() does not check the parent's class. store() throws InvalidArgumentException for a model of the wrong class, but associate() only reads $parent->{$this->ownerKey}. So $article->author()->associate(new Bio()) silently sets author_id to the bio's id, which is the kind of orphan write this PR is meant to prevent. Please add the same class check and a test for the wrong-class case. CI does not cover this path today.

Non-blocking:

  • Model::newRelationBuilder() now copies every non-static Builder property from $related::query() on each relation access. This is what makes query() overrides apply to relations, but it also adds an extra builder per access and copies any future property automatically. A short comment explaining why it is needed would help.
  • Relations on an unsaved model now match nothing, where they used to match NULL foreign keys. This is tested and documented, but it is a behavior change worth a release note.

Once the associate() check is in, this looks good to merge.

@abdulmajidcse please check now

@abdulmajidcse abdulmajidcse left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@techmahedy, everything is ok now.

@techmahedy

Copy link
Copy Markdown
Member Author

@techmahedy, everything is ok now.

ok, perfect

@techmahedy
techmahedy merged commit 9ad682d into doppar:4.x Oct 18, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants