Skip to content

Fire model.relation.beforeAdd/afterAdd events from AttachOneOrMany::create() - #244

Open
wakqasahmed wants to merge 2 commits into
wintercms:developfrom
wakqasahmed:fix/attach-relation-events-on-create
Open

Fire model.relation.beforeAdd/afterAdd events from AttachOneOrMany::create()#244
wakqasahmed wants to merge 2 commits into
wintercms:developfrom
wakqasahmed:fix/attach-relation-events-on-create

Conversation

@wakqasahmed

@wakqasahmed wakqasahmed commented Sep 4, 2026

Copy link
Copy Markdown

Summary

AttachOneOrMany::create() builds and persists the related model directly via parent::create(), bypassing add() entirely whenever no sessionKey is given — the normal, immediate (non-deferred-binding) path most $model->relation()->create(...) calls take. add() is what actually fires model.relation.beforeAdd/model.relation.afterAdd, and also handles deleting the existing sibling attachment for AttachOne — so both of those silently never ran through create().

Fix

Route create() through add() in both branches: when sessionKey is null, build an unsaved model via $this->related->newInstance($attributes) so add()'s own $model->save() is the only place the model actually gets persisted (avoids a double-save). The deferred-binding path (sessionKey !== null) still uses parent::create(), unchanged, since the model needs to exist immediately for the deferred-binding table row that follows.

The single-attachment sibling-deletion logic that used to live directly in create() is removed from there — add() already has the identical if ($this instanceof AttachOne) { $this->delete(); } check, so nothing is lost, just de-duplicated onto the one method both save() and create() now share.

Testing

Added testCreateFiresRelationEvents to both AttachOneTest and AttachManyTest (confirmed each fires exactly once, with the correct relation name and model instance, by binding model.relation.beforeAdd/afterAdd and asserting on the callback args). Added testCreateReplacesExistingSingleAttachment to AttachOneTest and testCreateDoesNotReplaceExistingAttachments to AttachManyTest to lock in the existing single-vs-multi behavior wasn't disturbed by moving the sibling-deletion check.

Verified red-before/green-after: the new event tests fail against the pre-fix code (0 events fired) and pass after. Ran the full tests/Database suite (222 tests, 1018 assertions) — all pass, no regressions.

Fixes wintercms/winter#1147.


Implemented with AI assistance (Claude Code); verified against a real PHP 8.2 test environment before submitting.

Summary by CodeRabbit

  • Bug Fixes

    • Creating multiple attachments through a collection relation now preserves existing attachments instead of replacing them.
    • Creating a new attachment through a single-item relation replaces the previous attachment.
    • Relation add events now fire consistently when attachments are created through relations.
  • Tests

    • Added coverage for attachment preservation and replacement behavior.
    • Added coverage confirming relation events are dispatched with the expected relation and attachment details.

…reate()

create() built and persisted the related model directly via
parent::create(), bypassing add() entirely whenever no sessionKey was
given (the immediate, non-deferred-binding path). add() is what fires
model.relation.beforeAdd/afterAdd and handles the single-attachment
sibling deletion for AttachOne, so both silently never ran when using
$model->relation()->create(...) directly instead of ->add().

Route create() through add() in both cases: build an unsaved model via
newInstance() when sessionKey is null so add()'s own $model->save()
is the only persist, keeping parent::create() for the deferred-binding
path where the model must exist immediately.
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 42f75f9a-4435-4ad5-8b44-a81bb06ae025

📥 Commits

Reviewing files that changed from the base of the PR and between d8d34e5 and 347e6f5.

📒 Files selected for processing (1)
  • phpstan-baseline.neon

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

AttachOneOrMany::create now routes created models through add, including models created without a session key. Tests verify relation events, multi-attachment preservation, and single-attachment replacement. The PHPStan baseline count decreases by one.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 347e6

Attachment creation now uses the common add path so relation add events fire while single and multiple attachment behavior remains covered. No current merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 3 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: firing relation events from AttachOneOrMany::create().
Linked Issues check ✅ Passed The implementation routes the immediate create path through add(), which fires model.relation.beforeAdd and model.relation.afterAdd. The added tests verify both events for AttachOne and AttachMany rel…
Out of Scope Changes check ✅ Passed The source change, regression tests, and PHPStan baseline update all support the event-handling fix and preserve attachment replacement behavior. No unrelated changes are present.
Full details: Docstring Coverage

Explanation

Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 3 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mjauvin mjauvin self-assigned this Sep 4, 2026
@mjauvin mjauvin added the maintenance PRs that fix bugs, are translation changes or make only minor changes label Sep 4, 2026
@mjauvin mjauvin added this to the 1.3.0 milestone Sep 4, 2026
@mjauvin
mjauvin requested a review from LukeTowers September 4, 2026 15:47
@mjauvin mjauvin added accepted Issues that have been accepted by the maintainers for inclusion and removed accepted Issues that have been accepted by the maintainers for inclusion labels Sep 4, 2026
@mjauvin

mjauvin commented Sep 4, 2026

Copy link
Copy Markdown
Member

Please, fix the PHPSTAN expected number of delete calls from 3 to 2.

Looks good to me otherwise.

@mjauvin

mjauvin commented Sep 4, 2026

Copy link
Copy Markdown
Member

Also, while you're at it, change the "private" in the error message to "public" (or remove altogether)

create() no longer has its own direct call to the parent's private
delete() (per the prior commit, it now routes through add(), which
already had one) -- the baseline's expected-count for this message
was never updated to match, so PHPStan silently over-tolerated one
fewer occurrence than actually exists. Verified via a clean
--memory-limit=1G analyse run with zero errors at count: 2.
@wakqasahmed

Copy link
Copy Markdown
Author

Fixed the PHPStan count — pushed. Verified with a clean vendor/bin/phpstan analyse --memory-limit=1G run (0 errors) after dropping the baseline entry from 3 to 2; create() no longer has its own direct call to the parent's private delete() since it routes through add() now (which already had one), so the expected-occurrence count needed to drop by exactly one.

On the second point — could you point me to which message/line you mean? I don't see a literal "private" string in anything this PR touches, and I want to make sure I fix the right spot rather than guess.

@mjauvin

mjauvin commented Sep 4, 2026

Copy link
Copy Markdown
Member

On the second point — could you point me to which message/line you mean? I don't see a literal "private" string in anything this PR touches, and I want to make sure I fix the right spot rather than guess.

In the phpstan rule you fixed for the count, the text mentions "private" although it is public.

@mjauvin mjauvin added the accepted Issues that have been accepted by the maintainers for inclusion label Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

accepted Issues that have been accepted by the maintainers for inclusion maintenance PRs that fix bugs, are translation changes or make only minor changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

model.relation.beforeAdd and model.relation.afterAdd events not called when using AttachOneOrMany::create()

2 participants