Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Safely Transaction Laravel Package

021/safely-transaction

A Laravel package for running database transactions safely, wrapping Laravel's transaction handling so failures roll back cleanly and transaction logic stays simple, predictable, and easy to use across your application.

View on GitHub
Deep Wiki
Context7

Getting Started

The package is small — one helper class that wraps Eloquent row locking in a transaction. Confirm the exact method signature by reading src/ before you wire it into anything; the public surface is short enough to skim in a minute.

composer require 021/safely-transaction

Requires a relational engine with real row-level locking (MySQL InnoDB or PostgreSQL). Laravel's package auto-discovery should register the service provider; if you're on an older Laravel, add it to config/app.php manually.

Where to look first, in order:

  1. composer.json — supported Laravel/PHP versions and the PSR-4 namespace.
  2. src/ — the helper class. Look for whether it takes a Model, a Builder, or a raw table name, and whether it returns the callback's return value.
  3. Any service provider — check for a facade alias so you know whether you call SafelyTransaction:: or inject the class.

First use case — the one it exists for. Two requests hit the same product row and both decrement stock. Without a lock, both read stock = 1, both write stock = 0, and you've sold two units of one item.

use SafelyTransaction\SafelyTransaction;

SafelyTransaction::lock($product)->run(function (Product $product) {
    if ($product->stock < 1) {
        throw new OutOfStockException();
    }

    $product->decrement('stock');
});

Under the hood this is DB::transaction(...) around a ->whereKey($id)->lockForUpdate()->first(), then your callback against the freshly locked row. Verify that the callback receives the reloaded model — that detail matters (see Gotchas).

Implementation Patterns

Guard-and-mutate. The dominant pattern: re-read inside the lock, assert the invariant, mutate, commit. Never assert on the model instance your controller already had in hand — that instance is a pre-lock snapshot.

SafelyTransaction::lock($account)->run(function (Account $account) use ($amount) {
    if ($account->balance < $amount) {
        throw new InsufficientFunds();
    }

    $account->decrement('balance', $amount);
    LedgerEntry::create([...]);
});

Note the ledger write is inside the same callback — that's the point of a transaction wrapper, and it's why you want the helper rather than hand-rolling lockForUpdate in five places.

State machines and status transitions. Enforce "only one worker can move this job from pending to processing":

SafelyTransaction::lock($job)->run(function (Job $job) {
    abort_if($job->status !== 'pending', 409);
    $job->update(['status' => 'processing', 'started_at' => now()]);
});

If your helper doesn't accept a conditional lock (e.g. lockWhere(['status' => 'pending'])), the check-inside-callback form above is equivalent and safer.

Idempotent queue jobs. Wrap the job's mutation body. If two workers pick up the same job (retry race, SQS duplicate delivery), the second one blocks, sees the already-updated state, and exits cleanly.

Multi-row locks. For transfers you need both rows locked, and always in the same order (e.g. ascending by primary key) or you'll manufacture deadlocks:

[$a, $b] = $a->id < $b->id ? [$a, $b] : [$b, $a];

SafelyTransaction::lock($a)->run(function () use ($a, $b) {
    // lock $b inside too, or nest helpers if the package supports it
});

Service/repository integration. Keep the helper at the service layer, not in controllers. A TransferMoney action that owns its own transaction is testable and reusable from HTTP, jobs, and commands.

Returning values. If the helper returns the callback result, use it as a read-after-write primitive. If it doesn't, capture into a closure variable or call ->fresh() on the outer model after the block — but only after commit, not inside.

Optimistic alternative. If contention is low (most rows are touched by one writer), a version column plus WHERE version = ? is cheaper than a pessimistic lock. Reach for this package when you have real hot rows: inventory, wallets, counters, seat maps.

Gotchas and Tips

  • The outer model is stale. The helper reloads inside the lock, but the $model you passed in (and any $order->items you eager-loaded) is not refreshed. Use the callback's argument, or call ->fresh() on the outer instance after the block.
  • Silent no-ops on the wrong engine. SQLite ignores lockForUpdate entirely, and MySQL MyISAM has no row locks. Your tests will pass and production will oversell. Check DB::getDriverName() in a boot assertion, or at minimum run lock tests against MySQL/Postgres in CI.
  • Read replicas break it. If the connection has a read/write split, the SELECT ... FOR UPDATE must run on the write connection. Force it: DB::connection('mysql::write'), or set 'sticky' => true on the connection.
  • Never do I/O inside the callback. No HTTP calls, no dispatch(), no mail. A lock held across a 3-second API call will queue every other writer on that row. Dispatch after commit with DB::afterCommit(fn () => ...) or dispatchAfterResponse.
  • Nested transactions are savepoints, not new transactions. If the helper is called inside an existing DB::transaction, the lock is scoped to the outer transaction's lifetime. That's usually what you want — but if the outer transaction is long, your lock is long. Audit callers.
  • Deadlocks are a design signal, not a bug to paper over. Add a bounded retry (DB::transaction($cb, 3)) at the outermost level, and fix the lock ordering.
  • Lock only what you touch. lockForUpdate() on a query with joins or select * across a relation can lock unintended rows (or fail outright on MySQL with certain join shapes). Lock the base table row, then read relations un-locked or with sharedLock.
  • Package maturity. One star, zero dependents. Pin the exact commit in composer.json ("021/safely-transaction": "dev-main#abc1234") or vendor the ~50 lines into app/Support/. You're not getting upstream fixes, and the logic is simple enough to own.
  • Extension points are thin. If you need sharedLock, custom where clauses on the lock, or lock timeouts (SET innodb_lock_wait_timeout), you'll be bypassing the helper and writing DB::transaction + lockForUpdate directly. That's fine — the package is a convention, not a framework.
  • Debugging hangs. When a request hangs on a lock, run SHOW ENGINE INNODB STATUS (MySQL) or query pg_locks / pg_stat_activity (Postgres). In Laravel, DB::listen() with timing will tell you which statement is waiting.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package