Laravel 13.23 adds a monthly log driver that rotates one file per calendar month, per-message tenant selection for the SES v2 mail transport, support for PostgreSQL's USING clause when changing column types, and a timing-safe comparison for the maintenance mode bypass secret. The Laravel team released v13.23.0 on July 27, 2026.
- New
monthlylog driver and matchingmonthlylogging channel X-SES-TENANT-NAMEheader support in the SES v2 transport->using()on->change()migrations for PostgreSQL type casts- Timing-safe maintenance mode bypass secret comparison
- A rewritten
schedule:listtimezone converter and assorted fixes
What's New
Monthly Log Driver
The daily driver rotates a file per day, which suits applications that log enough to fill one. Smaller applications end up with a directory of near-empty files instead. The new monthly driver sits between single and daily, writing one file per calendar month (laravel-2026-07.log, laravel-2026-08.log, and so on):
'monthly' => [ 'driver' => 'monthly', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'max_files' => 3, 'replace_placeholders' => true,],
Both rotating drivers now share a createRotatingDriver() method on the LogManager and differ only in the Monolog date format they pass (FILE_PER_DAY versus FILE_PER_MONTH) and their retention default: 14 files for daily, 3 for monthly.
Retention also has a new configuration key. The daily channel reads max_files first and falls back to the existing days key, so applications that already set days keep working and the LOG_DAILY_DAYS environment variable is unchanged. The shipped config now uses max_files for both channels. Contributed by @SjorsO in #60892, with the config channel added by @yazansalhi in #60895.
SES Tenant Support in the SES v2 Transport
Amazon SES tenants isolate sending reputation and configuration between groups of senders. Until now the transport could only work with a tenant configured for the whole application. The SES v2 transport now reads an X-SES-TENANT-NAME header off the outgoing message and forwards it as the TenantName parameter on the SDK call, following the pattern already used for X-SES-LIST-MANAGEMENT-OPTIONS.
You can set the header from a mailable:
use Illuminate\Mail\Mailables\Headers; public function headers(): Headers{ return new Headers(text: ['X-SES-TENANT-NAME' => 'tenant-id']);}
Or from a notification's mail message:
return (new MailMessage)->withSymfonyMessage(function ($message) { $message->getHeaders()->addTextHeader('X-SES-TENANT-NAME', 'tenant-id');});
When the header is absent, sending behaves exactly as before, and the parameter is omitted on AWS SDK versions that predate the feature. Contributed by @atymic in #60886.
->using() for PostgreSQL Column Changes
PostgreSQL refuses to alter a column to an incompatible type without an explicit cast, returning an error like column "name" cannot be cast automatically to type date. There was no way to supply that cast through the schema builder, so migrations written against MySQL or SQLite would fail on PostgreSQL. Column definitions now accept a using() clause:
Schema::table('currency_rates', function (Blueprint $table) { $table->date('name')->using('name::date')->change();});
The clause applies to PostgreSQL only; other grammars ignore it. Contributed by @shemgp in #60901.
Timing-Safe Maintenance Mode Bypass Secret
The maintenance mode bypass flow compared the secret two different ways: hash_equals() when validating the incoming secret, and a plain === in the PreventRequestsDuringMaintenance middleware and the maintenance-mode.stub bootstrap path. Both comparisons now use hash_equals(), and the middleware's isset() guard was replaced with is_string() so a non-string cookie value cannot raise a TypeError.
This is a defense-in-depth change rather than a fix for an exploitable bug, since a wrong-length guess never reaches a byte-by-byte comparison. Behavior for valid input is unchanged. Contributed by @yazansalhi in #60896.
Other Fixes and Improvements
- The
schedule:listtimezone converter was rewritten. It previously shifted only plain numeric cron fields, leaving ranges (8-23), steps (*/2), and wildcards untouched while still labeling the output with the converted timezone. It now expands each field, shifts the values, and collapses them back into compact syntax, splitting an event into multiple entries when a shifted range crosses midnight against a fixed day field. It also accounts for DST transitions and fractional offsets such as UTC+5:45 (#60877) - Failures inside the deprecation logger no longer escalate into fatal errors (#60893)
CookieJar::queued()returns the supplied default correctly (#60904), andArr::last()handlesnullwithout erroring (#60887)ImageManager::fromStorage()accepts a backed enum as the disk name (#60889), and theImageclass received type improvements (#60890)Collection::select()has more accurate generic types for static analysis (#60898)- The earlier fix for
ShouldBeUniqueUntilProcessingjobs force-releasing locks they do not own was reverted (#60905)
References