publishNext() throws ValidationException::withMessages() for invalid
story overlays (bad text/hashtag/mention/url/type), but the enclosing
catch (\Exception $e) caught it too, since ValidationException extends
Exception. Clients got a generic 500 "Failed to create story" instead
of the intended 422 with field-level errors, and the log was flooded
with ordinary user-input validation failures logged as server errors.
Catch ValidationException separately, roll back, and rethrow it so
Laravel's exception handler renders the normal 422 response.
store() and update() open a manual DB::beginTransaction() but had early
return paths (duplicate keyword, keyword not found, per-filter keyword
limit) that hit neither DB::commit() nor the catch block's rollback,
leaving the transaction open past the end of the request. Add the
missing DB::rollBack() call on each early-exit branch, matching the
manual-transaction pattern used elsewhere in the app.
and notification routing
All 15 queues previously ran through one auto-balanced supervisor.
Horizon's `balance: auto` does not honor queue array order for
priority, so despite queue names implying priority ('high' vs 'low'),
a burst on any one queue could starve any other sharing that
supervisor - e.g. a burst of mmo (image/video optimization, 23
dispatch sites, CPU/IO heavy) could delay high-queue DM/follow
delivery just as easily as it could delay low-queue background work.
Split into 4 supervisors grouped by actual job characteristics
(checked via grep across every ->onQueue() call site, not guessed):
- supervisor-priority: high, inbox, pushnotify, follow, default,
shared - user-facing federation/DM/notification delivery.
- supervisor-fanout: feed, story, groups - bursty timeline/story
fanout writes triggered by posts, likes, and follows.
- supervisor-media: mmo - image/video optimize/resize/thumbnail.
Runs a fixed worker pool (balance: false) instead of auto-scaling,
so it can't claim workers away from the other pools under load.
- supervisor-background: low, delete, adelete, move, intbg - imports,
crawling, account deletion/migration; not time-sensitive.
Moved the shared supervisor shape into `defaults` (keyed per
supervisor name, per Horizon's own merge behavior) so `environments`
only needs to override what actually differs, instead of each
environment fully redefining supervisor-1 from scratch. Existing env
vars (HORIZON_MAX_PROCESSES, HORIZON_MIN_PROCESSES,
HORIZON_BALANCE_STRATEGY, HORIZON_SUPERVISOR_*) keep governing the
priority supervisor for continuity with existing deployments; the
three new supervisors get their own HORIZON_*_MAX_PROCESSES vars
with conservative defaults.
Also:
- Added balanceCooldown: 3 explicitly (previously relied on
SupervisorOptions' own constructor default of the same value -
behavior is unchanged, just no longer implicit).
- Wired LongWaitDetected notification routing
(Horizon::routeMailNotificationsTo/routeSlackNotificationsTo) to
new optional config('horizon.notification_routing') keys, sourced
from env vars. Previously these were hardcoded, commented-out
examples with nowhere to actually alert on the `waits` thresholds
already configured below.
Verified by actually starting `php artisan horizon` and inspecting
`horizon:supervisors`: all 4 supervisors registered with exactly the
intended queues, supervisor-media correctly running fixed (non-auto)
balancing. Cross-checked every ->onQueue() call site in app/ against
the new supervisor queue lists - exact match, no queue dropped or
duplicated. Full test suite (715/715) and Larastan clean.
config/queue.php's redis connection defaulted retry_after to 90s,
while config/horizon.php's supervisor-1 defaults to a 300s timeout
in both environments. Per Horizon's documented timeout-chain
requirement (job timeout < supervisor timeout < retry_after), this
was backwards: any job legitimately running between 90s and 300s
would get treated as dead by the queue driver and picked up by a
second worker before Horizon's own supervisor had a chance to time
it out, causing the same job to run twice concurrently.
Bumps the default to 330s (a 30s margin over the supervisor timeout)
and documents the relationship inline so it doesn't regress if
either value is tuned later.
- laravel/framework ^12.0 -> ^13.0
- spatie/laravel-backup ^9.2.9 -> ^10.0 (forced: 9.x pins
illuminate/notifications ^12.40, incompatible with L13)
- Drop psalm/plugin-laravel + vimeo/psalm (dev-only static analysis):
the only version chain compatible with L13's testbench-core needs
vimeo/psalm ^7.0.0-beta, which requires narrowing the project's
declared PHP floor (composer platform.php is pinned to 8.3.0 to
keep composer.lock installable on the oldest supported PHP patch;
the psalm 7 betas require specific 8.3.16+/8.4.3+/8.5.0+ floors).
Its CI workflow (.github/workflows/php-psalm.yml) was already
disabled (`on: []`, "too many errors"). Larastan/PHPStan remains
as the project's static analysis tool, unaffected.
- Rename VerifyCsrfToken/ValidateCsrfToken -> PreventRequestForgery
in bootstrap/app.php and config/sanctum.php (the L13 rename; old
classes remain as deprecated aliases but new code should reference
the new name), and validateCsrfTokens() -> preventRequestForgery()
in the middleware config.
Everything else (cache serializable_classes, cache/session/redis key
prefixes, upsert() uniqueBy, JobAttempted/QueueBusy event properties,
pagination view names, Manager::extend bindings, model-boot nested
instantiation) was checked against the app's actual code and found
to be either already handled, already using the new convention, or
not applicable to any pattern in this codebase.
All 715 tests pass (verified against a clean baseline with Redis
available locally via Docker); Pint and Larastan (the project's
configured `composer analyse` scope) are both clean.
Aligns with the app's dominant convention (84 files vs 10). Avoids
running migrations for tests that never touch the database.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Aligns with the app's dominant convention (171 static Str:: calls vs
24 Str::of() chains). Uses Str::afterLast() for the repeated
"segment after last slash" pattern, Str::matchAll() where a
Collection return is needed, and native explode()/substr() where a
plain array/string suffices.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Aligns with the app's dominant convention (413 now()/today() call
sites vs 12 Carbon::now()). Carbon::parse() calls are untouched since
they parse arbitrary date strings, not current-time access.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>