Repository navigation
v1.86.0 - Alpherg
[1.86.0] - 2026-09-22 — Alpherg
Upgrade Notes
- Config lists are replaced, not merged by position. A list your app's config sets (a
config/schedule.phpjobslist, above all) now replaces the framework's list whole instead of
being merged into it entry by entry. If you relied on framework entries past the end of your own
list, add them to it. To get the newwebhook_cleanupjob, add it to yourjobs. - Delete the config nothing read from your own copies (listed under Removed). Leaving it
changes nothing. - List your permission middleware in
permissions.enforcing_middlewareif you enforce
permissions in route middleware, sopermissions:diffcounts them. security:checkcan now fail where it used to pass: its checks run for real. Fix what it
reports (a world-readable.env, a short signing secret, CORS*with credentials …).- A scheduled backup that makes no dump now fails its job instead of logging "completed", and
it takes the stock database config: a PostgreSQL or MySQL site needspg_dumpormysqldump
on the scheduler host. - "Send test event" refuses local and private addresses, as queued deliveries already did;
test against a public endpoint or a tunnel.
Added
- Failed queue jobs can be listed, retried and removed. A job that exhausted its attempts went
toqueue_failed_jobswith no way back: no command, no screen.queue:failedlists them (job
class, queue, error),queue:retry <uuid>… | --all [--queue=]puts them back as new jobs,
queue:forget <uuid>deletes one andqueue:flush [--queue=]deletes them all. They work on any
connection whose driver implements the newGlueful\Queue\Contracts\FailedJobStore: the
database driver (queue_failed_jobs) and the Redis driver (queue:{name}:failedlists) both do.
A retry verifies the stored payload's signature first, so a payload altered after it failed is
refused, never re-signed. webhook:cleanupand a scheduledwebhook_cleanupjob.api.webhooks.cleanup
(keep_successful_days,keep_failed_days) was read by nothing, so delivery records, payloads
included, were kept for ever.Webhook::cleanup()deletes delivered records past the first and
failed records past the second; pending and retrying ones stay.WebhookCleanupJobruns it daily
from the defaultconfig/schedule.php(WEBHOOK_CLEANUP_ENABLED). A site whose own schedule
replaces the default adds the job itself.
Fixed
-
An app's config lists replace the framework's instead of merging by position. Config files
were layered witharray_replace_recursive, which merges lists index by index: an app's third
scheduled job took every key it lacked (enabled,parameters,queue…) from the framework's
third job, and framework jobs past the end of the app's list ran too. Maps still merge key by key;
a list an app sets (or a package default a file overrides) now replaces the list below it. -
permissions:diffsees permissions enforced by route middleware. It read only
#[RequiresPermission]/#[RequiresRole]attributes, so an app enforcing permissions as
->middleware('content_permission:content.view')had every permission reported as declared but
unenforced. List such middleware in the newpermissions.enforcing_middlewareconfig; their
parameters (comma-separated) count as enforced. -
security:checkchecks what it reports. Five of its seven steps were hard-coded passes that
printed "validated". Now: health checks the database answers; file permissions checks.envis
private to its owner andstorage/is writable; configuration checksAPP_KEY,JWT_KEYand
TOKEN_SALTare set and at least 32 characters; authentication checks access tokens live at most
a day and refresh tokens at most 90 days; network checks CORS does not allow every origin with
credentials. The security commands also receive the booted container and context instead of
building an unbooted one. -
The production validation recommends only what does something. It recommended
FORCE_HTTPSandHSTS_HEADER, which nothing reads, and judgedDB_PASSWORDwhatever the
engine, so a PostgreSQL site with an empty password passed. It now judges the active engine's
password (DB_PGSQL_PASSWORDfor pgsql,DB_PASSWORDfor mysql, none for sqlite). -
A working database queue reports healthy.
DatabaseQueue::healthCheck()probed the
connection with a table-less query the builder refuses, so it always reported the connection
failed. -
LOG_RETENTION_DAYSand the notification retry limit take effect. The default schedule passed
retentionDaysandlimitwhereLogCleanupJobandNotificationRetryJobread
options.retention_daysandoptions.limit. -
Saved values are data, not SQL.
QueryValidatorrefused any value reading like"; delete …"
or"; drop …": a CMS import failed on the sentence "would be deleted; delete nothing". Values
are always bound parameters, so the check protected nothing. It also raised a warning for any
value over 64 KB, which the error handler turns into an exception, so a long article could not be
saved. Values are no longer inspected; the empty-array check (an invalidIN ()) and the refusal
of an UPDATE or DELETE without conditions stay. -
FailedJobProviderworks against the table the migration creates. It was written for
columnsqueue_failed_jobsnever had (retryable,retry_count,job_class,
exception_class,last_retry_at):log()failed on insert,retry(),retryAll()and
getStats()failed on read, the requeue step was a stub that never put a job back, and the
hourly trend used MySQL'sHOUR(). It now records, lists, requeues (after verifying the stored
signature), forgets, flushes, prunes and summarises over the stock columns on every engine, with
the job and exception class read from the payload and message. Public signatures are unchanged;
it gainsrequeue()(returns the new job's uuid and throws on a refused payload),
flushCount(),prune()and an optional sixth constructor argument, the driver a retry pushes
onto. The database driver and thequeue:*failed-job commands now go through it, so there is
one implementation. Filters other thanconnection,queue,from_dateandto_dateare
refused rather than ignored. A stored failure'sexceptionnow starts with the exception class.
QueueMaintenancereports the number of failures it pruned, not1. -
Deleting a webhook subscription deletes its deliveries. Nothing linked them (the tables are
created at first use, with no foreign key), so the rows stayed, reachable from no endpoint. -
Webhook::reset()also clears the context, so state set by one test cannot leak into the
next. -
A model created through the ORM carries its own id.
Model::performInsert()took
insert()'s return value as the new primary key, butinsert()returns the affected-row count,
so every auto-increment model came back with id 1: a queued job, child row or later update keyed
on it pointed at the first row in the table. NewQueryBuilder::insertGetId()(with
InsertBuilder::insertGetId()andQueryExecutor::executeInsertGetId()) reads the generated id
from the connection that ran the insert, and the ORM uses it. When an insert generates no id (a key
filled by a column default rather than a sequence, where PostgreSQL has nolastval), it returns
null and the model's key is left as the database set it, instead of failing the insert. -
Webhooks deliver.
WebhookDispatcherandWebhook::retry()handedQueueManager::push()a
DeliverWebhookJobobject where it takes a class name, aTypeErrorunderstrict_types: every
delivery row stayedpending, the event listener's error was only logged, and Retry answered 500.
Both now queue throughDeliverWebhookJob::enqueue(), which pushes the class and the delivery id;
a retry goes to the configuredapi.webhooks.queue. The job scaffold's dispatch examples showed
the same object form and now showpush(Job::class, $data, $queue). -
A test webhook cannot reach private addresses.
Webhook::test()posted to any URL; only a
queued delivery checked the destination. It now applies the delivery's guard (scheme, localhost,
private, reserved, link-local and metadata ranges) and its DNS pinning before any request. -
The scheduled database backup backs up.
DatabaseBackupTaskread flatdriver,database,
usernameandpasswordkeys the stockconfig/database.phpdoes not have, so every stock site
took themysqldumppath with empty credentials; thePGPASSWORDit prepared was never passed to
pg_dump, and the MySQL password went on the command line. It now readsengineand that
engine'shost/port/db/user/pass, runs the dump tool without a shell with the password in
PGPASSWORDorMYSQL_PWD(andPGSSLMODEfromsslmode), writes and prunes backups in one
directory (app.paths.backups, elsestorage/backups), and logs "failed" instead of "completed"
when no backup was made.DatabaseBackupJobpasses its context to the task and fails when no
backup was created, so the queue records it andfailed()logs it. -
Two database-queue workers never run the same job.
DatabaseQueue::pop()selected the next
job and reserved it by uuid alone. The reservation is now a claim that lands only while the row is
still unreserved; a worker that loses it tries the next candidate. No row locks are needed, so it
holds on SQLite, MySQL and PostgreSQL.
Removed
- Config that nothing read. The
syncandnullqueue connections (no such drivers exist);
the schedule'ssettingsblock,queue_mapping, and each job'squeue,timeoutand
retry_attempts(config jobs run inline in the scheduler process; the file now says so);
app.force_https; thesecurity.headersblock (thesecurity_headersmiddleware is configured
per route); the unusedforce_httpskeys in that middleware's profiles; and from.env.example,
HSTS_HEADER,FORCE_HTTPS,SCHEDULE_QUEUE_*andMAIL_BCC. Delete them from an app's own
copies of these files; leaving them changes nothing.
Deprecated
FailedJobProvider::setMaxRetries()andgetMaxRetries(). A retry creates a new job with
fresh attempts and the table keeps no retry count, so nothing enforces the value. Removal in
1.88.