Prepare a Laravel app for Laravel Cloud — SQS FIFO message groups, edge-cacheable public pages, Horizon removal. Use on `/laravel-cloud-ready`, or when a deploy hits MissingParameter MessageGroupId.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add bravros/bravros --skill laravel-cloud-ready --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Laravel Cloud Ready?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/bravros-laravel-cloud-ready-205cbdbc)More formats (shields.io, HTML) on the badges page.
---
name: laravel-cloud-ready
description: Prepare a Laravel app for Laravel Cloud — SQS FIFO message groups, edge-cacheable public pages, Horizon removal. Use on `/laravel-cloud-ready`, or when a deploy hits MissingParameter MessageGroupId.
---
# laravel-cloud-ready
INTENT: close the three gaps that break a Laravel app the first time it runs on Laravel Cloud —
FIFO queues, edge caching, and Horizon — before the deploy finds them.
Verified against **Laravel 13.25.0**. The mailable/notification asymmetry in § FIFO is
framework-internal; re-read the vendor source before trusting it on a newer major.
## 1. SQS FIFO — three paths, three different hooks
Laravel Cloud's managed queue is a `.fifo` queue. Every queued object must carry a message group
or the push dies with:
```
MissingParameter (Sender): The request must contain the parameter MessageGroupId
```
`SqsQueue::getQueueableOptions()` inspects **the object actually pushed to the queue** — reading
`$job->messageGroup` (property) first, then `$job->messageGroup()` (method). Which object that is
differs per path, and that is the whole trap:
| Path | Object SQS inspects | Hook | `messageGroup()` on the class? |
|---|---|---|---|
| Job | the job itself | `messageGroup()` | ✅ read directly |
| **Mailable** | `SendQueuedMailable` wrapper | override `newQueuedJob()` → `->onGroup()` | ❌ **silently ignored** |
| Notification | `SendQueuedNotifications` wrapper | `messageGroup()` | ✅ `NotificationSender` forwards it |
**Jobs** — plain method:
```php
public function messageGroup(): string
{
return 'ebook-'.$this->ebookId;
}
```
**Mailables** — a `messageGroup()` on the mailable is never read. `SendQueuedMailable`'s
constructor copies `connection`, `queue`, `tries`, `timeout`, and `maxExceptions` from the
mailable — but not the group. Overriding the wrapper's construction is the only attachment point:
```php
trait GroupsQueuedMail
{
abstract public function messageGroup(): string;
protected function newQueuedJob()
{
return parent::newQueuedJob()->onGroup($this->messageGroup());
}
}
```
**Notifications** — no trait needed, and this asymmetry is the part people get wrong in both
directions. `NotificationSender::queueNotification()` already reads `messageGroup()` off the
notification and forwards it via `->onGroup()` to the wrapper. Just declare the method.
**Choosing the group key.** Group per entity (`post-{id}`, `lead-{id}`, `media-{id}`), never a
constant — a single group serializes everything in it. Deliberately *share* a key only when
ordering matters: a lead's download link, follow-up mail, and scheduled sends all belong in
`lead-{id}` so sequence 1 precedes sequence 2.
## 2. The guard test — hand-written datasets always miss one
A dataset only proves the classes someone remembered to list. Walk the source tree instead, and
cover `app/Notifications` as well as `app/Jobs` — in practice the notification is what gets missed:
```php
it('leaves no queued job or notification without a group', function (string $dir, string $ns) {
$missing = collect(glob(app_path($dir.'/*.php')))
->map(fn (string $path) => $ns.'\\'.basename($path, '.php'))
->filter(fn (string $class) => is_subclass_of($class, ShouldQueue::class))
->reject(fn (string $class) => method_exists($class, 'messageGroup'))
->values();
expect($missing)->toBeEmpty("No messageGroup(): {$missing->implode(', ')}");
})->with([['Jobs', 'App\\Jobs'], ['Notifications', 'App\\Notifications']]);
```
**Prove it is not vacuous.** A glob typo makes this pass over an empty set forever. Print the
enumeration once and confirm the real classes appear:
```bash
php artisan tinker --execute 'echo collect(glob(app_path("Jobs/*.php")))->map(fn($p) => "App\\Jobs\\".basename($p, ".php"))->filter(fn($c) => is_subclass_of($c, \Illuminate\Contracts\Queue\ShouldQueue::class))->implode(", ");'
```
Assert the wrapper too, not just the method — the method is not what SQS reads:
```php
Queue::fake();
$lead->notify(new EbookDownloadLink($ebook, $activity));
Queue::assertPushed(SendQueuedNotifications::class,
fn ($job) => $job->messageGroup === 'lead-'.$lead->id);
```
## 3. Edge caching — the point is hibernation
Requests served at the edge never reach compute, so the environment sleeps instead of being woken
by crawlers. Laravel Cloud caches HTML only when the app says so, and **refuses any response
carrying `Set-Cookie`** — which Laravel queues on every web response. Two non-obvious blockers:
- **`Set-Cookie` must be removed**, not just `Cache-Control` set. Removing the header also empties
Symfony's cookie bag, which is what actually unblocks the edge.
- **Livewire's `DisableBackButtonCacheMiddleware`** stamps `no-store` plus `Pragma` and a 1990
`Expires` on every full-page component. Laravel Cloud honours all three — clear all three.
`prepend()` to the **global** stack so the middleware unwinds last: after the session cookie is
queued and encrypted, and after Livewire's globally pushed middleware.
```php
$response->headers->remove('Set-Cookie');
$response->headers->remove('Pragma');
$response->headers->remove('Expires');
$response->headers->set('Cache-Control', 'public, max-age=0, s-maxage=3600, must-revalidate');
```
**`s-maxage` only.** Laravel Cloud purges the edge on every deployment, so an edge copy can never
outlive the deploy that replaced it. A private browser copy gets no such purge — hence
`max-age=0, must-revalidate`. Revalidation is answered by the edge, not the origin, so hibernation
still works.
**Never cache** — each of these is a real outage, not a precaution:
| Excluded | Why |
|---|---|
| Livewire form routes | one shared CSRF token vs each visitor's own session ⇒ 419 on every post |
| Authenticated requests | leaks one user's page to everyone |
| Non-200, non-cacheable methods | caches errors |
| UTM-tagged requests | a cached response sets no cookie, so no session exists to record attribution |
**Strip the CSRF meta tag** from cacheable HTML. `<meta name="csrf-token">` bakes in the token of
whichever request populated the edge. Inert while no JS reads it — then silently 419s for a whole
TTL window the moment some does. An absent tag fails loudly; a wrong one fails for an hour. After
`setContent()`, remove `Content-Length` — a stale length truncates the body.
## 4. Horizon is incompatible
Laravel Cloud manages queue workers itself. Remove `laravel/horizon`, `config/horizon.php`, the
`HorizonServiceProvider`, its panel/nav registration, and any `horizon:snapshot` schedule entry.
Removing the composer dep means production needs a `composer install` on deploy.
## Verify
```bash
php artisan route:list --except-vendor >/dev/null; echo "boot rc=$?" # boots without Horizon
```
Post-deploy, confirm on a cacheable route: `cache-control: public, max-age=0, s-maxage=3600,
must-revalidate`, no `set-cookie`, no `csrf-token` meta — and that a form route still posts
without a 419, a UTM landing still records attribution, and one mail plus one notification clear
the `.fifo` queue.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!