shipmonk/memory-scanner
ShipMonk's Memory Scanner is a lightweight PHP utility for measuring memory usage around callbacks and code blocks. It helps compare implementations, track allocations, and spot leaks or unexpected memory growth in tests and long-running processes, keeping memory consumption under control.
Install as a dev dependency only — this is a diagnostic tool, not runtime infrastructure:
composer require --dev shipmonk-rnd/memory-scanner
Check composer.json in the package for the PHP floor (it targets modern PHP 8.x) before adding it to a Laravel app pinned to an older runtime.
Where to look first
src/MemoryScanner.php — read the ~200 lines once. You need to understand how it decides something is a leak (it snapshots live objects before/after a callback and reports survivors), because every false positive you hit later comes from that definition.First use case: assert a service doesn't leak in a test
The highest-value, lowest-risk adoption is a PHPUnit/Pest test around a single hot path — an importer, a report builder, a job handler:
use ShipMonk\MemoryScanner\MemoryScanner;
it('does not leak while building the report', function () {
$builder = app(ReportBuilder::class);
// warm caches/container so lazy singletons aren't counted as leaks
$builder->build();
(new MemoryScanner())->checkMemoryLeaks(function () use ($builder) {
$builder->build();
});
});
That's the whole loop: install, wrap one callable, run the suite. Once that test is green and stable, promote the scanner into the places where leaks actually hurt — Octane workers and queue consumers.
Pattern 1 — middleware around the request in Octane
Octane keeps the container alive across requests, so a leaked singleton degrades the worker over hours. Wrap the unit of work, not the response:
class LeakCheckMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (! config('app.debug') || ! app()->bound(MemoryScanner::class)) {
return $next($request);
}
$scanner = app(MemoryScanner::class);
$result = $scanner->checkMemoryLeaks(fn () => app(ReportBuilder::class)->build());
return $next($request);
}
}
Note the shape: the leak check wraps a self-contained call whose return value you don't keep. If you wrap $next($request) itself, the Response object survives by design and will be reported — see Gotchas.
Pattern 2 — queue job middleware
Long-running queue:work processes are where leaks are most expensive. A job middleware gives you per-job attribution, which is far more useful than a single line in the log:
class DetectJobLeaks
{
public function handle(Job $job, Closure $next): void
{
if (! config('queue.leak_check')) {
$next($job);
return;
}
try {
(new MemoryScanner())->checkMemoryLeaks(fn () => $next($job));
} catch (MemoryLeakException $e) {
report(new RuntimeException("Leak in {$job->resolveName()}", previous: $e));
}
}
}
Register it per-job via middleware() rather than globally — you want this on the three jobs that matter, not on all of them.
Pattern 3 — artisan loop guard
For custom long-running commands (while (true) { process(); }), run the scanner on a sampled iteration (every 1000th) rather than every loop, and let it fail loudly. Compare against memory_get_usage() manually alongside it to distinguish "objects retained" from "allocator never returned pages".
Pattern 4 — baseline before asserting
Always do one untracked warm-up call. Laravel lazily resolves bindings, boots Eloquent models (Model::$booted), populates config caches, and registers listeners on first use. That first-call furniture looks exactly like a leak to a naive snapshot diff. Two calls — warm-up then measured — is the pattern that keeps the suite honest.
Pattern 5 — container hygiene between checks
If you assert across several units in one test, reset scoped state so leak A doesn't get attributed to unit B:
app()->forgetInstance(ReportBuilder::class);
app()->forgetScopedInstances(); // Octane/Laravel 10+
gc_collect_cycles();
Integration tip: wrap the package in your own base-test helper (InteractsWithMemory) so the class name appears in exactly one file. With 18 stars and a single maintainer, you want the ability to swap it for a 50-line inline snapshot diff without touching 40 tests.
The container is a leak by design. Every singleton(), every app(X::class) you never release, every once() memoization (PHP 8.1's per-object WeakMap cache) will survive the callback and be reported. This is the #1 source of noise. Either call forgetInstance()/forgetScopedInstances() before measuring, or configure the package's ignore/filter list for Illuminate\* classes.
Anything your callback returns or stores counts. If the wrapped callable returns an object, or writes to a property/global, that object outlives the check and is indistinguishable from a leak. Wrap work whose output you can discard.
Closures capturing $this are a classic false positive — and a real Laravel bug pattern too. An event listener registered as function () use ($this) that never gets unregistered pins the whole service graph. Check Event::getRawListeners() if a leak points at a listener you thought was transient.
memory_get_usage() lies about bytes. PHP's chunked allocator doesn't return freed pages to the OS; memory_get_usage(true) will happily sit at 4 MB while your actual object graph is flat. This is why object-level scanning beats byte-diffs for leak hunting — but it also means a byte regression test is a different (and flakier) tool.
Garbage collection matters. Run gc_collect_cycles() before the after-snapshot, and never leave gc_disable() in a test bootstrap — reference cycles ($parent->children[] = $child; $child->parent = $parent) are exactly the leaks you're hunting, and disabled GC turns them into permanent survivors.
Xdebug distorts everything. Its memory overhead per function call is enormous and it changes refcount behaviour. Run leak tests with Xdebug off (php -n or a separate CI job); otherwise you'll chase ghosts, or worse, get false negatives.
Eloquent retains more than you think: Model::$booted, Model::$globalScopes, relation caches on loaded models, and Connection::$recordsModified. Hydrating 10k models inside a leak check will light up the report. Chunk with lazyById() and unset, or exclude Illuminate\Database\Eloquent\Model in the filter config.
Don't leave it on in production. Reflection and debug_zval_dump-style introspection are slow and allocate. Gate it behind APP_DEBUG / a dedicated env flag, and prefer sampling over per-request execution.
Extension points to lean on: the per-class ignore/filter list (use it aggressively — vendor namespaces first), and your own assertion wrapper. There's no Laravel service provider in the package, so register the scanner as a scoped binding yourself if you inject it.
Maintenance reality check: 18 stars, one org, zero dependents. Pin an exact version in composer.json and read the source before upgrading. The technique, not the package, is the durable asset — a minimal snapshot-diff of get_object_vars/class counts around a callable is a weekend's work if the package ever stalls on a new PHP minor.
Debugging tip when the report is noisy: halve the wrapped scope repeatedly (whole test → one method → one query) until the leak count drops. The scanner tells you what survived, not where it was allocated; bisecting the callable is still the fastest way to localise it.
How can I help you explore Laravel packages today?