Skip to content
Back to skills

Laravel Cloud Ready

ASecurity

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.

  • 3 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 5, 2026
ai-agentsrustphpbashaws

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 22, 2026

npx -y skills add bravros/bravros --skill laravel-cloud-ready --agent claude-code

Installs 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 with every re-scan.

Security grade badge for Laravel Cloud Ready
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/bravros-laravel-cloud-ready/badge)](https://www.skillsdirectory.com/skills/bravros-laravel-cloud-ready)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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
composer show aws/aws-sdk-php >/dev/null 2>&1; echo "aws-sdk rc=$?"    # required for the managed queue
```

**`aws/aws-sdk-php` must be a composer dependency.** Laravel Cloud's managed queue is SQS
underneath, and a deploy without this package fails with "Your application has a managed queue
but is missing the [aws/aws-sdk-php] package." Add it (`composer require aws/aws-sdk-php`) before
the first deploy that enables the managed queue, not after the failure.

**Scale-to-zero / hibernation wake interval is a dashboard setting, not code.** The hourly (or
other) wake cadence that keeps a hibernating environment checking in is configured in the Laravel
Cloud dashboard for that environment — there is no config file, env var, or code path to search
for it. Point the operator at the dashboard instead of hunting the repo.

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.

Files in this skill

  • SKILL.md7 KB
  • skill.yaml7.6 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.