emerson-pombo / idempotency-linter
Static analyzer that finds Laravel queue jobs without protection against re-execution (idempotency) before they become production incidents.
Package info
github.com/Emerson-Pombo/idempotency-linter
pkg:composer/emerson-pombo/idempotency-linter
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- nikic/php-parser: ^5.0
Requires (Dev)
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.13
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Composer package for Laravel that statically analyzes queue job classes (ShouldQueue) and finds jobs that perform side effects sensitive to duplication — charges, emails, database inserts — with no protection against re-execution.
⚠️ Status: pre-release (0.x). The analysis engine, the Artisan command and the JSON output already work, but the format of the detection catalogs may still change between 0.x versions. See the CHANGELOG. Use it as a development and CI tool, not as a production dependency.
The problem
Queue systems (Laravel Queues, BullMQ, Sidekiq, Celery) run under an at-least-once guarantee: network failures, timeouts and deploy restarts can make the same job run more than once. Making each job idempotent is left to the developer, by hand — and that process is prone to human error. In codebases with dozens of jobs, it is common for some to be left unprotected, and the problem only shows up in production as a visible incident (a duplicate charge, a repeated email, a duplicate record).
Today, existing tools (idempotency libraries, deduplication middleware) only work at the manual prevention layer: the developer explicitly decorates the code with an idempotency key. No public tool automatically audits existing code to flag unprotected jobs before they become an incident.
The idea
A static linter (built on nikic/php-parser) that walks the handle() method of every Laravel job, finds calls with dangerous side effects ("sinks") and checks whether a recognized idempotency guard ("guards") protects each one. Unprotected jobs are reported with a risk level and the exact location in the code.
composer require --dev emerson-pombo/idempotency-linter php artisan idempotency:scan app/Jobs
🔴 HIGH RISK — app/Jobs/ProcessPaymentJob.php:16
Call to a payment gateway without an idempotency check.
Job: App\Jobs\ProcessPaymentJob
❌ 2 jobs analyzed, 1 at risk, 1 protected.
Scope
- A Composer package that installs in any Laravel project.
- The
idempotency:scanArtisan command. - Static analysis of
ShouldQueueclasses, starting athandle()and following the project's own code. - Customizable sink and guard catalogs through a publishable config.
- A risk report (high/medium/low) with the exact file and line.
Out of scope for now: dynamic/runtime analysis, distributed idempotency across microservices, automatic fixes and other queue ecosystems (BullMQ, Sidekiq, Celery). They are possible future work.
How the analysis works
For each ShouldQueue job, the linter reads the body of handle() and:
- Looks for sinks: starting at
handle(), and in the project methods it calls, it looks for calls from thesinkscatalog (payment, email, notification, HTTP, database insert). Each call found produces a finding with the exact line. - Looks for guards: calls from the
guardscatalog (Cache::lock/Cache::add,firstOrCreate/updateOrCreate/upsert, an idempotency key in arrays such as['idempotency_key' => ...]). A guard only protects what comes after it in execution order (the body of a followed method counts at the point where it is called); a guard placed after the sink does not count. An idempotency key also protects the call that receives it as an argument. - Accounts for partial protection: if the job implements
ShouldBeUnique, the risk of each finding drops one level (high → medium → low) and low-risk findings are no longer reported.ShouldBeUniqueprevents concurrent jobs, but it does not cover retries or redelivery.
What is followed
The linter follows the flow into the project's code, up to 5 levels deep, without repeating a method that is already running:
- methods of the class itself:
$this->method(),self::method()andstatic::method(); - injected services with a declared concrete type:
$this->service->method()(a property, including one promoted in the constructor) and$service->method()(a parameter ofhandle()), plus static calls to project classes (Helper::method()); - inherited methods: defined on the parent class,
parent::method(), andhandle()itself when the child job does not define it; - methods of project traits, and typed properties coming from the parent or from traits.
A finding points to the real file and line of the sink (for example, inside the service) and to the job that reaches it. If several jobs use the same service, each one produces its own finding. Classes from vendor/, interfaces, and calls that are already catalog sinks or guards are not followed inside.
Methods are recognized when the type of the object is known: static calls (Mail::send()), functions, and methods on properties (including ones promoted in the constructor) or handle() parameters with a declared type ($this->stripe->create() with PaymentIntentService $stripe). Subclasses, interfaces and traits from the catalog also match (Invoice::create() with Invoice extends Model).
Chained calls are followed when they are declared in the chains catalog, which says what type each method returns. By default it covers Mail::to($u)->cc($c)->send($m), Http::withToken($t)->acceptJson()->post($url) and Notification::route('mail', $to)->notify($n). The reported line is the one where the chain starts.
Limitations of v1
- Dependencies typed by interface are not followed (the implementation is only known to the container at runtime), nor are services obtained through
app(Service::class),newin a local variable, or method injection outsidehandle(). - Classes from
vendor/(including framework traits such asQueueable) are not followed inside. - No type inference: local variables, untyped properties and chains that are not in the
chainscatalog (for exampleapp(Foo::class)->send()) are ignored, without an error. - A guard only counts by its position in the code; a guard inside an unrelated
ifstill protects the sink. - Jobs that inherit the interface from a base class (
extends BaseJob) or use an interface that extendsShouldQueueare detected, as long as the base class can be resolved: in the same file, or loadable through the autoloader. A parent that fails to load is ignored.
Heads-up: autoloading
To resolve subclasses and traits, the linter loads your project's classes through Composer's autoloader (class_exists, is_a, class_uses). It does not instantiate or run classes, but autoloading a class with file-level code runs that code. Classes that fail to load are ignored. Run the command in the project's environment, as you would any Artisan command.
Installation
Requires PHP 8.1 or higher and Laravel 10, 11, 12 or 13.
composer require --dev emerson-pombo/idempotency-linter
The ServiceProvider is registered automatically (Laravel package auto-discovery).
Usage
# analyze the paths defined in the config (default: app/Jobs) php artisan idempotency:scan # one or more files/directories php artisan idempotency:scan app/Jobs app/Domain/Billing/Jobs # control the exit code (useful in CI): high | medium | low (default) | none php artisan idempotency:scan --fail-on=high
The command exits with code 1 if there is any finding at the --fail-on level or above.
JSON output
With --format=json (default: text) the command prints only a JSON document to stdout, with no icons or warnings, and keeps the same exit code:
php artisan idempotency:scan app/Jobs --format=json --fail-on=none > idempotency.json
{
"version": 1,
"summary": {
"jobs": 2,
"risky_jobs": 1,
"protected_jobs": 1,
"findings": 1,
"errors": 0
},
"findings": [
{
"file": "app/Jobs/ProcessPaymentJob.php",
"line": 16,
"job": "App\\Jobs\\ProcessPaymentJob",
"sink": "payment",
"risk": "high",
"message": "Call to a payment gateway without an idempotency check."
}
],
"errors": []
}
findingsis sorted from highest to lowest risk.riskishigh,mediumorlow.errorslists paths that do not exist and files with syntax errors, which do not interrupt the scan.versionchanges when the format changes in an incompatible way.
A GitHub Actions example that fails only on high risk and keeps the report:
- run: php artisan idempotency:scan --format=json --fail-on=high | tee idempotency.json
Configuration
php artisan vendor:publish --tag=idempotency-linter-config
This creates config/idempotency-linter.php with the default paths and the catalogs of sinks (side effects: payment, email, notification, HTTP, database insert), guards (Cache::lock/Cache::add, firstOrCreate/upsert, idempotency key, ShouldBeUnique) and chains (known call chains). The format of these catalogs may still change. In function rules, the function names go in the functions key (or methods).
Roadmap
- Base structure: ServiceProvider, publishable config,
idempotency:scancommand,ShouldQueuejob discovery - Analysis engine: detect sinks inside
handle() - Detect guards and decide whether they protect each sink
- Resolve chained calls declared in the catalog (
Mail::to()->send(),Http::withToken()->post()) - Follow the class's own methods from
handle() - Follow injected services, inherited methods and project traits from
handle() - Recognize jobs that inherit
ShouldQueuefrom a base class - JSON output for CI (
--format=json) - Resolve interfaces to their concrete implementation
- SARIF output
Development
composer install composer check # code style (Pint) + static analysis (PHPStan level 6) + tests composer format # applies the code style automatically
CI runs the tests on PHP 8.1 to 8.3 with Laravel 10, 11, 12 and 13 (compatible combinations), plus Pint and PHPStan.
Contributing
Issues and discussions are welcome. See CONTRIBUTING.md.