Search by

brewless / laravel

Jaspur

What a Laravel application needs to run on Brewless: the release, schedule and queue routes its own containers answer on, and trust in the edge in front of it.

Package info

github.com/brewless/laravel

Homepage

pkg:composer/brewless/laravel

Statistics

Installs: 1 445

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0


README

What a Laravel application needs to run on Brewless. It runs in your containers, in your own cloud account, and keeps working when you stop using Brewless. It calls Brewless only when your application asks it for a database per tenant (see below); otherwise it sends nothing anywhere.

composer require brewless/laravel

There is nothing to register. Laravel 12 and 13, PHP 8.3 and up. The AWS SDK (aws/aws-sdk-php) comes along: Laravel's sqs connection needs it, and a queue at Scaleway Queues is where Brewless puts the jobs of a new environment.

What it adds

Release operations over HTTP

Nothing stays running on a serverless container, and a one-off job cannot reach a private database. So the containers of an environment answer on a few routes of their own:

Route What it does
POST /_ops/migrate Runs the release commands (migrate --force) before a release goes live; with {"commands": [...]} those instead
POST / with {"task": "tick"} One scheduler tick, then the queue; the body a cron trigger sends. {"task": "queue"} only works the queue, and "queues": ["mail", "default"] names the queues to work, the first first
POST /_ops/schedule, POST /_ops/queue The same two, separately
POST / with a queued job A job a queue trigger delivers (Scaleway Queues), run once its signature holds; see below
GET /_ops/status Last tick, jobs waiting, jobs failed (which, never why), and the moments of the schedule
POST /_ops/queue/retry Puts every failed job back on the queue
POST /_ops/command Runs one console command, without questions

Every route needs the secret from BREWLESS_OPS_SECRET, in the header X-Brewless-Ops or as secret in the JSON body. A container without that variable has none of these routes: they answer 404, the same as a path that does not exist. Give the secret only to the containers that should do this work; the container that serves visitors does not need it.

PHP ends a web request after 30 seconds by default, shorter than release commands or a command may take. A request with the secret gets ops.time_limit seconds instead (900); the container's own timeout at your provider still applies.

Queue triggers

A queue at Scaleway Queues can call the worker as soon as a job is put on it, so nothing has to look at the queue every minute. Such a trigger posts the job itself to /, without any secret. So every job the sqs and database connections queue is signed, and only a job whose signature holds is run: a job is code that gets unserialised. The signing key is derived from the application key (APP_KEY), not from BREWLESS_OPS_SECRET: every container of the application queues jobs, also the one that serves visitors and has no ops secret, and all of them share the application key. After a rotation of APP_KEY, a job signed with a key in APP_PREVIOUS_KEYS still runs. It runs the way queue:work runs it, with its tries, backoff, failed() and the failed jobs table. The answer is always a success, also for a job that failed or that was dropped: the provider would otherwise deliver it again every half minute, without end.

A signed job also carries the moment it was signed and its attempts, so a message that was seen once cannot be posted again at will. It is good for ops.job_max_age seconds (BREWLESS_JOB_MAX_AGE, 3600) and dropped after that, or when it was signed more than a minute ahead of the container's clock. Within that time each delivery runs once: one that comes again is dropped, and one that is still being handled by another request gets a 409, so the provider tries it again later (a delivery whose container stopped before it answered runs again after ops.time_limit). A job that goes back on its queue, and a failed job that is retried, is signed again and runs as a delivery of its own. The marks are kept in the application's default cache; give the containers a cache they share (redis, the database), or a delivery to another container is not recognised. Without a cache that answers, a delivery runs.

A failure is answered with ok: false and reported to the application's log, never with its reason; a release command that fails is named, so Brewless can say which one stopped the release.

Commands that never return (queue:work, tinker, serve, …) and commands that throw data away (migrate:fresh, db:wipe, …) are refused on /_ops/command, and as release commands sent along with a release. Brewless sends the release commands and queues of your project's brewless.yml; without them the commands of ops.release_commands and the default queue are used. Every wrong secret gets the same 404 and is counted per address; an address that gives a wrong secret ten times in a minute is logged. It is not shut out: the right secret, or a job whose signature holds, always passes, so someone guessing behind the same address as Brewless (the provider's front end) cannot lock Brewless out. What keeps the secret from being found by trying is its length. A tick that finds no job due does not start a worker, so an idle application costs a single query.

The visitor's hostname behind the edge

Scaleway routes a request on its Host header and only knows the container's own domain, so the edge sends that Host and carries the real hostname in Cdn-Host. The package puts it back, but only when the request also carries the secret from BREWLESS_EDGE_SECRET, and only for the domains in BREWLESS_DOMAINS (default: the host of APP_URL) and their subdomains. Calling the container directly with a made-up hostname changes nothing. A request with the secret is also marked as HTTPS: the edge forces HTTPS for every visitor, so the URLs Laravel generates (assets, links, redirects) start with https:// without trusting a proxy in TrustProxies.

Brewless can replace that secret without a moment in which the edge and the container disagree. It hands the container the new secret together with the one before it in BREWLESS_EDGE_SECRET_PREVIOUS, both of which count in full, then points the edge at the new one and takes the old one away again. Nothing to set yourself: a container that holds two does so for the minutes the replacement takes. GET /_ops/status reports under edge_secrets how many it holds, never which.

The visitor's address is treated the same way. With an edge secret set, only the address the edge saw is kept in X-Forwarded-For, and a request without the secret loses the header, so rate limits count the real visitor and nobody chooses their own address. Scaleway's front end appends addresses of its own network (100.64.0.0/10) behind the edge's entries; those, and private addresses, are skipped first. BREWLESS_EDGE_HOPS says how many entries the edge itself writes: 2 by default, bunny.net's edge and its Origin Shield, which every pull zone Brewless manages has. Without an edge secret there is no edge, but there is the provider's own front end, which appends the address it saw: only that entry is kept, so a visitor cannot choose their own address there either.

HTTPS without an edge

An environment without an edge is visited on the container's own address at Scaleway, <name>.functions.fnc.<region>.scw.cloud. TLS ends at Scaleway's front end, so the request reaches the container as plain http, and without help Laravel would write http:// into its pages; the browser then blocks the assets as mixed content. A request on that address is marked as HTTPS, so the URLs come out as https:// without URL::forceScheme() or TrustProxies.

The package goes by the hostname, not by X-Forwarded-Proto. A name under Scaleway's functions domain reaches only the container it belongs to, and Brewless provisions every container with http_option: redirected, so Scaleway answers plain http with a redirect and lets only HTTPS through. A header can be written by anyone who calls the container, and the package cannot tell whether the front end overwrote it. Requests through the edge and on any other host, local development included, keep the scheme they had.

Pages the edge keeps

A serverless container sleeps only after a quiet spell (15 minutes at Scaleway), and every request that reaches it starts the count again. Behind an edge that keeps nothing, every visitor and every crawler wakes it, so a public site hardly ever sleeps. So an answer that cannot depend on who asked gets Cache-Control: public, max-age=0, s-maxage=3600, and the edge (bunny.net) answers the next request for that address itself. That is the case when:

  • the request is a GET or HEAD without cookies and without Authorization, and not for /_ops/*;
  • the answer is a 200, 301, 308, 404 or 410, sets no cookie, and started no session;
  • the application did not set Cache-Control itself;
  • it varies on nothing but Accept-Encoding;
  • the page did not ask who is signed in (@auth, auth()->user()): the edge keeps one answer per address, whatever cookies come along, and would give a guest's page to a signed-in visitor.

max-age=0 keeps it out of the browser: Brewless empties the edge on every release, so after a deploy nobody gets a page of the release before. A pull zone Brewless manages keeps a page per hostname, so a subdomain never gets the page of the domain. An edge of your own must do the same (at bunny.net: vary the cache by hostname), or turn this off. BREWLESS_EDGE_CACHE sets the seconds (3600); 0 turns it off.

A route in Laravel's web group starts a session, stores it and sets its cookie and XSRF-TOKEN, and an answer with a cookie is never kept. So the package leaves the session out for a guest who does not need one: a GET or HEAD without any cookie and without Authorization gets no session cookie and no XSRF-TOKEN, and nothing is read or stored, when

  • the page put nothing in the session but what Laravel puts there itself (the CSRF token, the previous URL, an empty flash bag), and
  • the answer does not carry the CSRF token: there is no form to post.

A page with a form, a page that puts something in the session (a flash message, an intended URL), a streamed answer and every visitor who sent a cookie keep their session exactly as before. With sessions in a database, a guest on a public page does not touch it either: the session of a request without a session cookie is started without a read, and garbage is collected only when a session is kept.

Two answers are asked for to get those cookies, and keep them: an empty one (Sanctum's /sanctum/csrf-cookie), and JSON that a script in a page fetched. The request has to show that, by one of X-Requested-With: XMLHttpRequest (what Axios and Sanctum send), an Accept that names JSON ahead of anything else (what fetch(url, { headers: { Accept: 'application/json' } }) sends), or Sec-Fetch-Dest: empty (what fetch() and XHR are given). JSON fetched without any of those is kept at the edge like any other page: a site.webmanifest the browser fetches itself and a .well-known file a crawler reads have no cookie to use. If a script of yours asks a route for its token, let it send one of the three; Axios does by itself.

A page whose script posts later without the token in its HTML (Inertia, the sign-in screen of a Vue app) relies on the XSRF-TOKEN cookie. Laravel 13's CSRF check lets a same-origin request through without a token, Laravel 12's does not. So this is on by itself on Laravel 13 only. BREWLESS_STATELESS_GUESTS=true turns it on on Laravel 12 too, BREWLESS_STATELESS_GUESTS=false turns it off.

To say it outright, or for a page that shows the CSRF token but needs no session, take the session off a group or a route:

use Illuminate\Support\Facades\Route;

Route::withoutSession(function () {
    Route::view('/', 'home');
    Route::get('/blog/{post}', [BlogController::class, 'show']);
});

Route::get('/pricing', PricingController::class)->withoutSession();

That takes StartSession, ShareErrorsFromSession and the CSRF middleware (PreventRequestForgery on Laravel 13, ValidateCsrfToken on 12) off those routes, for every visitor, signed in or not. Inside an existing group, such as the web group of routes/web.php, put it inside that group.

A page whose body your own middleware changes — compression, minifying — is read back as the visitor reads it before this decides. Compression that the application appends to its web group runs deeper than the session middleware, so without that the token would be hidden from the check and every page would go to the edge carrying a token of a session nobody kept. An encoding the package cannot undo makes it keep the session rather than guess.

Keep a page out of the edge by setting Cache-Control yourself, for instance with ->middleware('cache.headers:private') or no-store. Do that for a page that depends on something the edge cannot see: the visitor's address, a header of your own, or the Accept header (the edge keeps one answer per address).

What keeps the container awake

php artisan brewless:sleep

Every public page is asked through the application's own middleware, without cookies, as a first visitor asks it, and the answer is held against the rules above. What comes back is the page, and the rule it failed: it keeps a session, it sets a Cache-Control of its own, it sets a cookie, the answer is a 302, it varies on a header, or it looked at who is signed in. Next to that: the files your pages name without a ?v=, which the edge fetches from the container instead of the storage zone, and the scheduled work that repeats within the hour and wakes the worker that often.

Nothing leaves the machine and no container is woken by it. --path=/pricing limits it to the pages you name, and --strict makes it fail when a page cannot be kept, for a hook or a pipeline.

A page can answer differently on a developer machine than it does live, and only the live answer decides what the edge keeps, so the command says which environment answered. For the answer that counts:

brewless command production "brewless:sleep"

The test address

Brewless gives every environment a test address, such as acme-shop-production.brewless.dev, that leads straight to the web container. A request on it is handled as a request for the host of APP_URL, so an application that ties its routes to its own domain answers there too. Every answer of the application on the test address carries X-Robots-Tag: noindex, nofollow, and its robots.txt disallows everything. A public/robots.txt of your own is the exception: the web server hands out existing files before the application runs, so that file is served as it is. The pages still carry noindex, which is what keeps them out of search results. Your own domains are left alone. The request carries the attribute brewless.preview when it came in on the test address.

On the test address the session cookie gets a __Host- prefix (and is Secure, for the whole path, without a domain). Every environment's test address sits under the same domain; a browser never lets one of them set or overwrite a __Host- cookie of another, so an application on a neighbouring test address cannot put a session of its own on yours. On your own domains the session cookie keeps its name and settings.

A cache in a bucket, with locks

Where an environment has a bucket and no Redis or database, Brewless names CACHE_STORE=storage with CACHE_STORAGE_DISK=s3: Laravel's storage cache, its values in Object Storage, which every container reads and which outlives a container that goes away. Laravel's own store of it has no locks; the package puts one in its place that has them, so Cache::lock(), a ShouldBeUnique job and WithoutOverlapping work. Object Storage cannot hold a lock, so the lock is Laravel's file lock on the container's own disk (storage/framework/cache/locks, or lock_path in the store's config): atomic within a container, not across containers. Work that must run only once across the whole environment needs a cache the containers share, such as Redis.

Public files on the files address

Brewless gives the bucket for files of an environment behind bunny.net an address of its own: cdn. and the host of APP_URL (cdn.example.com), or a cdn- name under the test domain when the environment has no domain of its own; storage.cdn in brewless.yml names another hostname or, with false, none. Only files stored with public visibility are served there; the bucket stays private, and a private file still needs Storage::temporaryUrl(). Once that address answers with its certificate, a deploy gives the containers AWS_URL with it, so Storage::disk('s3')->url() names it instead of the bucket; until then the bucket's own address is used. An AWS_URL of your own always wins. The package needs nothing for it: Laravel's s3 disk reads AWS_URL itself.

A cache over TLS

A cache at Scaleway (cache in brewless.yml) is a Managed Database for Redis on the Private Network of your project, reached over TLS with a certificate of its own cluster. Brewless gives the application that certificate as BREWLESS_REDIS_CA (base64), next to REDIS_HOST, REDIS_PORT, REDIS_USERNAME and REDIS_PASSWORD. With it, the package makes the default and cache redis connections speak TLS and trust that certificate and no other (brewless.redis.connections names them). The host is an address on the Private Network, which no certificate names, so the name is not checked; the certificate is. Without the variable your redis connections are left as you configured them.

Through a database restart

A managed database is sometimes restarted: Scaleway's Serverless SQL Database disconnects its clients for at most a minute during maintenance, by default on Tuesdays between 14:30 and 14:45 CEST. Laravel reconnects and runs a query once when its connection went away, at once, which is too early then. The package gives the pgsql and mysql drivers a connection that tries the query again with pauses of 1, 2, 4 and then 8 seconds, for up to BREWLESS_DB_RETRY_SECONDS (60). Never inside a transaction, as in Laravel: what ran before in it went with the connection. An application with a connection class of its own for a driver keeps it.

Connections that stay open

BREWLESS_DB_PERSISTENT=true keeps the pgsql, mysql and mariadb connections open between requests (PDO::ATTR_PERSISTENT): every PHP thread of FrankenPHP, two per vCPU, holds one, and a request no longer starts with a TLS handshake with the database. On Serverless SQL that took a vote endpoint from 184 to 335 requests a second on the same 20 containers, and the median from 6.3 s to 67 ms. Off unless you turn it on; a connection that sets PDO::ATTR_PERSISTENT itself keeps its own choice.

  • Each container holds as many connections as it has threads, also while it is quiet: containers × threads has to stay under what the database accepts. A Serverless SQL Database accepts more as it gets more vCPU, up to 1,000. An open connection does not keep it awake.
  • A connection handed to the next request in the middle of a transaction, left by a request that died, is rolled back before anything runs on it.
  • After the database restarted (a boost, a failover, maintenance) the first query on an old connection fails as a lost connection and is tried again on a new one, as above.
  • Session settings stay with the connection, so the next request on that thread gets them. Never rely on one: Serverless SQL shares sessions anyway.

A cache in cluster mode

With cache: {name: …, nodes: 3} in brewless.yml the cache is a cluster of three to six nodes, and Brewless sets BREWLESS_REDIS_NODES to every node (host:port, comma-separated). The package then makes the default and cache redis connections a phpredis cluster of those nodes, with the connection's own user and password and the cluster's certificate over TLS, so Cache and Redis work as before. A redis connection of your own is left as it is.

A database per tenant

An application that gives every customer of its own a database of its own makes that database while it runs. On a Serverless SQL Database at Scaleway CREATE DATABASE over SQL is never allowed: each database is a resource of its own, with its own host, made through Scaleway's API. With databases in brewless.yml the application asks Brewless instead:

environments:
  production:
    database: shop-main
    databases:
      prefix: shop-

Brewless then gives every container BREWLESS_API_URL, BREWLESS_ENVIRONMENT and BREWLESS_DATABASES_KEY, and the client signs each request with that key:

use Brewless\Laravel\Databases\DatabaseClient;

$databases = resolve(DatabaseClient::class);

$database = $databases->create('shop-'.$tenant->id);      // being made
$database = $databases->waitUntilReady($database->name);  // looks every 2 s, 120 s at most

config(['database.connections.tenant' => [
    ...config('database.connections.pgsql'),
    'host' => $database->host,
    'port' => $database->port,
    'database' => $database->name,
]]);

$databases->find('shop-'.$tenant->id);    // null when Brewless did not make it
$databases->delete('shop-'.$tenant->id);  // removed at Scaleway now, with everything in it

A new database signs in with the application's own DB_USERNAME and DB_PASSWORD; only the host differs. The name has lowercase letters, digits and dashes, at most 63, and starts with the prefix: a Serverless SQL Database takes no underscore in its name, and the database inside has the same name. Quote it in SQL ("shop-1"), since PostgreSQL reads a bare dash as a minus. configured() says whether the three variables are there.

Every request carries X-Brewless-Environment, X-Brewless-Timestamp and X-Brewless-Signature: an HMAC-SHA256 with the key, as it stands in the variable, over the method, the path, the time in seconds and the SHA-256 of the (empty) body, each on a line of its own. The key itself is never sent, and never in an exception. A request has databases.timeout seconds (15), 5 of them to connect.

What can go wrong is a Brewless\Laravel\Databases\DatabaseException: RequestRefused when Brewless says no (a wrong key or a clock more than five minutes off, a name it does not make; ->status is the HTTP status), BrewlessUnreachable when Brewless or Scaleway cannot be reached or asks to slow down (safe to ask again), and DatabaseNotReady when waiting ends in an error or takes too long.

Database capacity boosts

A Serverless SQL Database grows with its load between the bounds Brewless gives it, but growing takes time, and a peak the application knows is coming (a ticket sale at ten, a newsletter at noon) is better met by a database that is already big. A boost asks Brewless for more vCPU for a while: at least min and at most max, from the start to the end. Brewless raises the database prewarm minutes before the start and lowers it again cooldown minutes after the end.

Boosts come with the Business plan and up. The owner switches the capacity key on for the environment in the Brewless console, which shows the key once, with BREWLESS_API_URL, BREWLESS_ENVIRONMENT and BREWLESS_CAPACITY_KEY. Put those three in the environment's variables (brewless env:push or the console); the next deploy gives them to the containers.

Type-hint Brewless\Laravel\Capacity\CapacityApi (or resolve it), so a test can put the fake in its place. A ticket shop that boosts its database for every sale, in an observer of its event model:

use Brewless\Laravel\Capacity\CapacityApi;
use Brewless\Laravel\Capacity\CapacityException;
use Brewless\Laravel\Capacity\CapacityRefused;
use Illuminate\Support\Facades\Log;

final class EventCapacity
{
    public function __construct(private CapacityApi $capacity) {}

    /** A new event with a sale start: the database is at 4 vCPU or more for the first two hours of the sale. */
    public function created(Event $event): void
    {
        $this->quietly(fn () => $this->capacity->schedule(
            "event-{$event->id}-sale",
            $event->sale_starts_at,
            $event->sale_starts_at->addHours(2),
            min: 4,
            max: 15,
            prewarm: 15,
            cooldown: 30,
            name: "Sale of {$event->name}",
        ));
    }

    /** The sale start moved: the same key, moved with it. Cancelled: the boost is called off. */
    public function updated(Event $event): void
    {
        if ($event->wasChanged('cancelled_at') && $event->cancelled_at !== null) {
            $this->quietly(fn () => $this->capacity->cancel("event-{$event->id}-sale"));
        } elseif ($event->wasChanged('sale_starts_at')) {
            $this->quietly(fn () => $this->capacity->reschedule(
                "event-{$event->id}-sale",
                $event->sale_starts_at,
                $event->sale_starts_at->addHours(2),
            ));
        }
    }

    /** The application goes on without the boost when Brewless cannot be reached or says no. */
    private function quietly(callable $call): void
    {
        try {
            $call();
        } catch (CapacityException $exception) {
            Log::warning('No capacity boost: '.$exception->getMessage());
        }
    }
}

And, if you like, a scheduled check a few minutes before a sale starts. It tells the team when the database is not there yet; it never holds the sale back:

try {
    if (! $capacity->ready("event-{$event->id}-sale")) {
        Notification::route('mail', 'team@example.com')->notify(new SaleDatabaseNotReady($event));
    }
} catch (CapacityException) {
    // Brewless out of reach: the sale goes ahead, as it would without a boost.
}
Method What it does
schedule($key, $starts, $ends, min:, max:, prewarm: 15, cooldown: 30, name:, database:) Plans a boost. The same key with the same values gives the same boost; the same key with other values is refused (409)
boost($key, $minutes, min:, max:, name:, database:) Starts a boost now, without prewarming
reschedule($key, $starts, $ends) Moves a boost that has not begun
update($key, [...]) Changes name, min_vcpu, max_vcpu, starts_at, ends_at, minutes, prewarm_minutes or cooldown_minutes of a boost that has not begun; once it began, only a later ends_at
extend($key, $until) Lets a boost run longer
cancel($key) Calls a boost off: one that has not begun at once, a running one ends now and cools down
status($key) The Boost as it stands (status, ready, startsAt, windowStartsAt, cpuCurrent, the estimate in cents), or null
ready($key) True only once Scaleway reports the database at the boost's minimum
databases() The databases the environment may boost, with their normal bounds and the bounds now
configured() Whether the three variables are there

database is only needed when the environment uses more than one database; it defaults to the environment's own Serverless SQL Database. A key has 1 to 100 letters, digits, dots, colons, underscores and dashes, and names one boost per database: use one per occasion (event-123-sale), never one per request.

Why prewarming. Raising the minimum wakes a sleeping database within seconds, and a small raise (0 to 4, 4 to 6 vCPU) takes seconds. A raise beyond what the database's current host holds makes Scaleway restart it: open connections are closed and it cannot be reached for up to about two minutes (96 to 110 seconds measured, 4 to 8 and 6 to 15 vCPU). That should happen before the peak, not in it: hence the 15 minutes of prewarming by default. The package tries a query again after a lost connection (see Through a database restart). Going down never restarts the database. Brewless never queries your database: what it reports comes from Scaleway's API.

Every request is signed like the databases client's, with BREWLESS_CAPACITY_KEY. When Brewless or Scaleway cannot be reached, is busy (5xx) or asks to slow down (429), the client tries again after half a second, a second and two seconds, each time with a new signature; that is safe because a key makes one boost. What can go wrong is a Brewless\Laravel\Capacity\CapacityException:

  • CapacityUnavailable: one of the three variables is missing, nothing was sent (a laptop, a test, an environment without the key);
  • CapacityUnreachable: still no answer after the last try; safe to ask again later with the same key;
  • CapacityRefused: Brewless said no, with ->status: 401 a wrong signature or a clock more than five minutes off, 403 a key that is switched off or a plan without boosts, 404 an unknown boost or database, 409 (->isConflict()) a key used before with other values, 422 (->isInvalid()) a value it does not take or a limit of the key, with ->errors per field.

In the application's own tests, CapacityClient::fake() puts an in-memory client in place of CapacityApi that sends nothing:

use Brewless\Laravel\Capacity\Boost;
use Brewless\Laravel\Capacity\CapacityClient;

$capacity = CapacityClient::fake();

Event::factory()->create(['id' => 7, 'sale_starts_at' => now()->addWeek()]);

$capacity->assertScheduled('event-7-sale', fn (Boost $boost) => $boost->minVcpu === 4);
$capacity->assertNotScheduled('event-8-sale');
$capacity->assertCancelled('event-7-sale');
$capacity->assertNothingScheduled();

$capacity->markReady('event-7-sale');    // ready() is false until a test says so
$capacity->unreachable();                // every call throws CapacityUnreachable

Mail

With Scaleway Transactional Email as the mailer (mail in brewless.yml, the default of a production environment with a domain), the package does two things, each only when Brewless gave the container its variables:

  • A copy of every mail sent, headers and content as it went out, goes to a private bucket of the environment (BREWLESS_MAIL_COPIES_*), under the id Transactional Email queued it as, for the Brewless console to show. Object Storage removes it after mail.keep_days. Storing a copy never stops a mail: a failure is logged.
  • POST /_ops/mail-events takes what Transactional Email reports of a mail from the topic Brewless subscribed (BREWLESS_MAIL_EVENTS_TOPIC), only with Scaleway's signature, confirms the subscription, and fires Brewless\Laravel\Mail\MailEvent:
use Brewless\Laravel\Mail\MailEvent;
use Illuminate\Support\Facades\Event;

Event::listen(MailEvent::class, function (MailEvent $event): void {
    if ($event->bounced() || $event->complained()) {
        // $event->to, $event->type ('email_mailbox_not_found', 'email_spam', …), $event->error
    }
});

For coding agents

The package teaches a coding agent (Claude Code, Codex, Cursor, Copilot, Junie and others) how an application on Brewless is deployed and run.

With Laravel Boost there is nothing to do. Run php artisan boost:install, or php artisan boost:update after updating, and pick brewless/laravel among the third-party packages. Boost then writes the files below where each of your agents reads them:

What Where it comes from What it does
Guideline resources/boost/guidelines/core.md Loaded in every session: what Brewless is, and the rules an agent never breaks on it
brewless-deployment resources/boost/skills/ Deploys, plans, releases, rollbacks, pipelines, and why a release failed
brewless-configuration resources/boost/skills/ Variables and secrets, brewless.yml, container sizes, PHP version, docker/ server files
brewless-operations resources/boost/skills/ Logs, running a command, queues and the schedule, scaling, domains and DNS
brewless-databases resources/boost/skills/ Databases, migrations, queues, buckets, backups, the cache, and a database per tenant
brewless-sleep resources/boost/skills/ Why a container never sleeps, what brewless:sleep reports, and what to do about each finding

The guideline goes into your AGENTS.md or CLAUDE.md. The skills go into .agents/skills, .claude/skills, .cursor/skills and so on, one folder per agent. A skill is loaded only when the task calls for it.

The rules of the guideline:

  • Ask before anything that changes an environment: a deploy, a rollback, env:push, scaling, DNS, or a command on production.
  • Never pass --force on the user's behalf.
  • Never put a secret in brewless.yml or the repository.
  • Do not rely on the local disk.
  • No FrankenPHP worker mode or Octane.
  • Leave /_ops/* and POST / to the package.
  • Let public pages go without a session (the package leaves it out for a guest whose page does not need one, or Route::withoutSession()), so the edge can keep them; set Cache-Control yourself on a page the edge must not keep.

Reading (plan, releases, logs, env:pull) an agent may do by itself.

Without Boost, publish the skills to where your agent looks for them:

php artisan vendor:publish --tag=brewless-skills          # .agents/skills (Codex, Amp, OpenCode, Zed, ...)
php artisan vendor:publish --tag=brewless-skills-claude   # .claude/skills (Claude Code)

The skills follow the Agent Skills format: a folder per skill with a SKILL.md.

Settings

Variable Meaning
BREWLESS_OPS_SECRET Turns the ops routes on, on this container
SCHEDULE_TIMEZONE The time zone of the schedule (Europe/Amsterdam), unless the application sets app.schedule_timezone itself
BREWLESS_EDGE_SECRET The secret the edge adds to every request
BREWLESS_EDGE_SECRET_PREVIOUS The secret before it, while Brewless replaces it; counts in full and is taken away again
BREWLESS_DOMAINS Comma-separated domains that are yours
BREWLESS_EDGE_HOPS Entries the edge writes into X-Forwarded-For, after the container network's own are skipped (2)
BREWLESS_EDGE_CACHE Seconds the edge keeps an answer that is the same for everybody (3600); 0 keeps nothing
BREWLESS_STATELESS_GUESTS Leave the session out for a guest without a cookie whose HTML page does not need one; unset: on Laravel 13 only. true on Laravel 12 too, false keeps a session on every page of the web group
BREWLESS_PREVIEW_DOMAINS Comma-separated test domains (brewless.dev); empty turns the test address off
BREWLESS_HEARTBEAT_STORE Cache store for the sign of life (file)
BREWLESS_JOB_MAX_AGE Seconds a job signed for a queue trigger is good for (3600)
BREWLESS_REDIS_NODES The nodes of a cache in cluster mode, host:port comma-separated; the redis connections then become a cluster
BREWLESS_REDIS_CA The certificate of the cache's cluster, base64; the redis connections then speak TLS and trust only that certificate
SQS_ACCESS_KEY_ID, SQS_SECRET_ACCESS_KEY, SQS_ENDPOINT The key and address of a queue at Scaleway Queues; handed to the sqs connection, which otherwise shares AWS_* with the s3 disk
BREWLESS_API_URL, BREWLESS_ENVIRONMENT, BREWLESS_DATABASES_KEY Where and as which environment to ask Brewless for a database per tenant, and the key to sign with; set by Brewless with databases in brewless.yml
BREWLESS_CAPACITY_KEY The key to sign capacity boosts with (with BREWLESS_API_URL and BREWLESS_ENVIRONMENT); shown once in the Brewless console when the owner switches it on
BREWLESS_DB_RETRY_SECONDS How long a query whose connection went away is tried again (60); 0 keeps Laravel's single try
BREWLESS_DB_PERSISTENT Keep the database connections open between requests (false)
RELEASE The running image; set by Brewless on each release

Other release commands, queue time and tries, or more commands to refuse:

php artisan vendor:publish --tag=brewless-config

Your config/brewless.php only has to name what it changes; every setting it leaves out keeps the package's value.

Without Brewless

The routes are plain HTTP. A cron trigger of your provider that posts {"secret": "…", "task": "tick"} to the worker container every minute is all the scheduler and the queue need; curl -X POST -H "X-Brewless-Ops: …" https://<container>/_ops/migrate runs your migrations.

Development

composer install
vendor/bin/pest
vendor/bin/pint

Licence

MIT. See LICENSE.