Skip to content
Back to blog
LaravelDevOpsSeguridad

Laravel 13.30 improves worker logs and hardens Storage paths

Laravel 13.30 makes worker shutdowns easier to observe and closes a path traversal gap in Storage::path().

Ismael Catala6 min read

When a worker stops and nobody knows why

A Laravel worker disappearing without context is more frustrating than the stop itself. In Docker, a homelab, or any server without an open terminal, I usually end up reading logs afterwards and trying to reconstruct what happened. Laravel 13.30 adds the shutdown reason to queue:work output, which is a small but genuinely useful operational change. (github.com)

This matters when container logs are sent to Loki, Elasticsearch, Grafana, or simply inspected through docker logs. Previously, it was possible to see that a process had exited without a direct indication of the reason. The worker can now emit a final record when it has a shutdown reason available. (raw.githubusercontent.com)

JSON logs that can drive alerts

The --json option on queue:work already exists to output worker information as JSON. In Laravel 13.30, the worker shutdown event follows the same format when that option is enabled. The record includes the stopped status, the reason, the exit code, processed jobs, and memory usage when it is available. (raw.githubusercontent.com)

I would enable it for workers running in containers where logs are collected outside the PHP process. There is no need to build a custom listener just to learn whether a worker exited because of memory, a job limit, a maximum runtime, or a restart signal. That information is now written to standard output, where it can join the rest of the operational data. (github.com)

services:
  worker:
    image: my-application:latest
    command: php artisan queue:work redis --json --sleep=3 --tries=3
    restart: unless-stopped

This configuration does not replace process supervision, but it leaves a structured clue before Docker decides whether to restart the container. It also makes more specific alerting possible than a generic “container restarted” notification. A memory-limit stop, for example, deserves a different response than an intentional exit after a maximum number of jobs.

A worker shutdown is not a failed job

The new log record explains why the worker stopped; it does not turn every failed job into a complete diagnostic event. Failed jobs still have their own retry flow, exceptions, and failed-job backend or table. Those event streams should be correlated, but they should not be treated as the same thing. (raw.githubusercontent.com)

There is another detail worth checking: Laravel does not write this output when the command runs with --quiet or --silent. If the goal is to collect these records from Docker or a log aggregator, those options suppress the signal you want to retain. That is sensible for a silent command, but deployment manifests and scripts may need an update. (raw.githubusercontent.com)

Storage paths can no longer take dangerous shortcuts

The other significant Laravel 13.30 change affects Storage::path(). That method returns the path for a file on a configured disk and, for local disks, it will generally be an absolute path. Starting with this release, Laravel normalizes the supplied value before building the path and rejects attempts to leave the disk root with segments such as ... (raw.githubusercontent.com)

The change fixes an important inconsistency: other storage operations already normalized paths, while Storage::path() could build a path outside the configured root. When an application built that path from user-controlled input, the result could point to a file outside the intended disk. It now throws PathTraversalDetected instead of returning a usable path. (github.com)

Review downloads built from requests

I would start by looking for controllers that accept a filename or path from a query string, route parameter, or form field. The risky pattern is not always obvious because input is sometimes concatenated with a prefix that appears safe. If the value comes from outside the application, it should not directly identify the file to download.

I prefer storing an application-generated relative path in the database and retrieving the document through an authorized identifier. Laravel can then serve the file from the disk without calling Storage::path() or manually assembling native file paths. That keeps user authorization, document identity, and physical storage location separate.

use Illuminate\Support\Facades\Storage;
 
Route::get('/documents/{document}', function (Document $document) {
    abort_unless($document->user_id === auth()->id(), 403);
 
    return Storage::disk('private')->download(
        $document->storage_path,
        $document->original_name,
    );
});

The download() method works with a path relative to the disk, as the rest of Laravel's storage API does. That does not remove the need to verify access to the document, but it avoids converting client input into an absolute filesystem path. Laravel's official documentation also states that storage paths should be relative to the configured disk root. (laravel.com)

The important limitations

This change does not repair weak authorization. Preventing a path from escaping a disk does not stop one user from downloading another user's private file when the application accepts predictable IDs and skips ownership checks. Path normalization is a technical boundary, not an authorization model.

I would not catch PathTraversalDetected and continue with an improvised fallback based on realpath(), string concatenation, or absolute paths. If the exception appears after an update, the likely causes are malformed input or code that relied on behaviour it should never have used. The productive response is to fix the flow and add a regression test.

There are valid cases where an application needs to work with files outside a Laravel disk, such as running a system tool against an infrastructure-managed directory. That path should come from application code or trusted configuration, never from a value received in an HTTP request. The Storage::path() change is not intended to secure every file API available in PHP.

chunkBy for adjacent groups

Laravel 13.30 also adds chunkBy() to collections. It groups adjacent items by comparing a key or callback result, which makes it useful when the collection is already ordered by the value that matters. It is not the same as collecting every matching value across the full collection. (github.com)

$blocks = collect([
    ['status' => 'new', 'id' => 1],
    ['status' => 'new', 'id' => 2],
    ['status' => 'paid', 'id' => 3],
])->chunkBy('status');

That example creates a new block whenever the adjacent status changes. If the same status appears again later, it becomes another separate block. For global grouping, groupBy() or a deliberate ordering step is still the better fit. (raw.githubusercontent.com)

An update to inspect, not just install

I do not see this as a release to install without looking at the application code. Worker shutdown records can noticeably improve observability in a small installation or a deployment with multiple containers. The Storage::path() hardening deserves a targeted project-wide search before production.

My checklist would be simple: enable --json where logs are centrally collected, make sure --quiet is not suppressing useful output, and find every call to Storage::path(). If any call receives request data, the answer should not be limited to blocking ... The client should request an authorized resource, while the application resolves the stored relative path internally.


Source: Laravel News Official documentation: Laravel Framework CHANGELOG