Skip to content

Fix withoutHook() and add withHooks() for bulk queries - #369

Merged
techmahedy merged 1 commit into
doppar:4.xfrom
techmahedy:issue-4.x
Oct 17, 2026
Merged

techmahedy merged 1 commit into
doppar:4.xfrom
techmahedy:issue-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

Summary

withoutHook() had several bugs that could change or delete the wrong data, or switch hooks off by accident for unrelated code. This PR fixes it and adds withHooks(), so hooks can run on bulk queries when you ask for it.

The problems

withoutHook() was a static method that set one flag shared by the whole PHP process, then returned a query builder. The flag was only switched back on when a model finished save(), update(), delete() or restore().

That caused three real bugs:

  1. Chained writes hit every row. $user->withoutHook()->update([...]) and $user->withoutHook()->delete() look like they act on $user. They don't. The call returns a fresh builder with no WHERE, so every row in the table is updated or deleted. In a test with three rows, the update changed all three and the delete emptied the table.
  2. The flag could stay off. If withoutHook() was followed by anything other than a model write (a read, a bulk builder update, an early return), hooks stayed off for the next model write, even on a different model. In a long-running worker this can go unnoticed for a long time.
  3. Chaining save() or updateOrCreate() threw an error. The builder has no save(), so $user->withoutHook()->save() failed with BadMethodCallException.

The only safe forms before this PR were two separate statements ($user->withoutHook(); $user->save();) and Model::withoutHook()->create([...]).

What changed

withoutHook() is now per model

  • The shared static flag is gone. Each model now has its own hooksSuppressed flag.
  • $user->withoutHook() sets the flag on that instance and returns the same instance. So ->save(), ->update([...]) and ->delete() can be chained safely and only touch that one record.
  • User::withoutHook() returns a builder (new Builder::withoutHook()). Every model the builder creates or loads through create(), find(), first() or get() skips hooks for its next write.
  • The builder also gets updateOrCreate() and firstOrCreate(), so User::withoutHook()->updateOrCreate(...) keeps working.
  • The flag is reset after every exit path of save(), update(), delete() and restore(), including the early returns that used to leak it.

New: withHooks()

User::where('status', 'pending')->withHooks()->update(['status' => 'active']);
User::where('status', 'pending')->withHooks()->delete();

A plain query builder write is a single SQL statement, so no model is involved and no hooks run. With withHooks(), the builder loads the matching records in chunks of 500 (by primary key) and calls each model's own update(), delete(), restore() or forceDelete(). Hooks, watches and temporal snapshots then run for every record.

  • Works with update(), delete(), restore() and forceDelete().
  • Rows that stop matching the WHERE after the update are not skipped, because the chunking walks the primary key.
  • Without withHooks(), nothing changes. Plain builder writes stay one fast query.

Behavior changes to know about

  • The old static two-statement form User::withoutHook(); User::create([...]); no longer skips hooks. The first call now returns a builder that is thrown away. Use User::withoutHook()->create([...]) or $user->withoutHook()->save(). I checked the repo: the one place that used the old form is Authenticate::logout(), which I updated to the instance form.
  • A model loaded through User::withoutHook()->find($id) carries the switch with it. If it is saved later, that first save skips hooks.
  • withHooks() is slower than a plain bulk update because every record gets its own query. It is opt-in.
  • withHooks()->update() follows the model's $creatable list, like $user->update([...]).
  • There is no implicit transaction. If a hook throws, earlier records stay changed and later ones are not touched. Callers can wrap the query in DB::transaction().

Checklist

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

@techmahedy techmahedy added bug Something isn't working feat new feature labels Oct 17, 2026
@techmahedy
techmahedy merged commit 94d8ddb into doppar:4.x Oct 17, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working feat new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant