NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Lifecycle for Laravel

Concurrency and databases

Every change — apply, rollback, freeze, schedule, expiry change, adoption, sweep execution — runs in one transaction on the subject’s connection:

  • One transaction, one lock order. Every change locks the subject row first, then the package’s rows for that subject, then quota lock rows. The state write is a compare-and-swap, so two concurrent apply() calls produce one history row; the other is refused with not_from_current_state.
  • check() is advisory. Between check() and apply(), someone else may act; apply() decides again under the lock.
  • Same database. The package writes its rows on the subject’s connection, so its tables must exist in that database. A sweep covers one connection: schedule lifecycle:sweep --database=<name> for models on another connection (lifecycle:validate warns when it is missing).
  • MySQL/MariaDB. Only a transaction that counts a quota — a transition or scheduled transition into a quota’d state, or a rollback on a definition with quotas — runs at READ COMMITTED, so the count after the quota lock sees the previous holder’s commit. Everything else (creating a model, other transitions, freezes, schedules, expiry changes, adoption) keeps your isolation level. Handlers, hooks and listeners of a quota’d transition run inside that READ COMMITTED transaction. READ COMMITTED needs row-based or mixed binary logging: with binlog_format=STATEMENT, a quota’d transition throws InvalidLifecycleConfigurationException naming the fix.
  • Opting out. Set transactions.mysql_read_committed to false (LIFECYCLE_MYSQL_READ_COMMITTED=false) to keep your isolation for quotas too: the count then takes locking reads, which never admit more than the quota but can deadlock under bursts into one scope (retried, then thrown). Inside your own transaction your isolation level always stays and quota counts use locking reads.
  • PostgreSQL at its default READ COMMITTED is fully supported; a host that runs REPEATABLE READ gets no quota guarantee.
  • Deadlocks are retried transactions.attempts times when the package opened the transaction. Inside your transaction, Laravel cannot retry a nested level: the error surfaces and your transaction is lost. Keep external side effects in after-commit listeners.
  • Bulk writes (Model::query()->update(), raw SQL) bypass the engine and are adopted later — see Strict writes and drift.
  • Octane and queues. Compiled definitions are immutable and shared; the manager keeps no state and resolves actions, guards, handlers, Gate, rate limiter and auth from the current container on every call (even when it was built at boot); allowDirectWrites() is scoped to the request or job.

The compare-and-swap

The state write is a single conditional update, sent without global scopes. If it affects no row, another writer won: ConcurrentTransitionException is thrown and everything rolls back:

UPDATE <table> SET <attr> = :to [, <stamps>] [, updated_at]
WHERE <key> = :id AND <attr> = :from

Duplicate idempotency keys, two sweepers over the same due rows and two concurrent rollbacks of one row are each settled by the subject lock and a unique index — one wins, and nothing applies twice.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.