Foxws
Laravel Podman

Customizing

You customize the package in two places:

  • config/podman.php, for names, paths and on/off switches. Publish it with php artisan vendor:publish --tag="podman-config".
  • The preset templates, for the services themselves: images, dependencies, memory limits and build files.

Config

Key Env variable Default Purpose
enabled PODMAN_ENABLED true Turns all podman:* commands on or off
quadlet_prefix PODMAN_QUADLET_PREFIX APP_NAME (or laravel) Prefix for service names, e.g. laravel-pgsql
proxy_prefix PODMAN_PROXY_PREFIX proxy Name of the proxy service and network
stubs_path PODMAN_STUBS_PATH containers/stubs Where your own presets live
base_path PODMAN_BASE_PATH Laravel's base_path() The project folder presets are read from and rendered into. Relative stubs_path/publish_path values resolve against it. Set it to render another project, e.g. from an installer container
working_path PODMAN_WORKING_PATH base_path Host path for {{workingPath}}/{{runtimePath}}. Override per run with podman:generate --working-path=
config_path PODMAN_CONFIG_PATH working_path Host path for {{configPath}}, for service config kept outside the project
quadlet_uid/quadlet_gid PODMAN_QUADLET_UID/_GID Your UID/GID Written into the Quadlet files
binary PODMAN_BINARY podman The Podman binary to run, such as for creating secrets with PodmanSecrets
publish_path PODMAN_PUBLISH_PATH podman Where rendered presets go. Don't commit this folder
selinux_volume_mapping PODMAN_SELINUX_VOLUME_MAPPING true Keeps the Z/z/U volume flags. Turn off on hosts without SELinux
presets PODMAN_DEFAULT_PRESETS see config/podman.php Presets that podman:setup renders
s3_buckets PODMAN_S3_BUCKETS see config/podman.php Buckets podman:s3-setup creates. See S3 Buckets
s3_cors_buckets PODMAN_S3_CORS_BUCKETS see config/podman.php Which of those buckets get the CORS policy
substitutions (none) [] Your own {{placeholder}} values. See Your own placeholders
ondemand.* PODMAN_ONDEMAND_* enabled Start the app on its first request and stop it when idle. See On-demand services

presets, s3_buckets and s3_cors_buckets take a PHP array or a comma-separated string.

Presets and templates

A preset is a folder with two directories: quadlets/ for the *.quadlets files, and runtimes/ for build files such as the Containerfile, entrypoint.sh, PHP ini and Caddy templates.

To change a preset, publish it to stubs_path (default containers/stubs) and edit the copy:

php artisan podman:publish production
# edit containers/stubs/production/quadlets/pgsql.quadlets
php artisan podman:generate production
lpod install production/pgsql.quadlets --replace

A published preset replaces the bundled one completely. Files aren't merged one by one, so the published folder must hold every file the preset needs.

  • Add a service: create containers/stubs/production/quadlets/my-service.quadlets in the same # FileName=... / --- format as the others. Then generate the preset and run lpod install production/my-service.quadlets.
  • Create a new preset: create containers/stubs/my-preset/quadlets/ and containers/stubs/my-preset/runtimes/.

Placeholders

Templates contain {{placeholder}} tokens. podman:publish leaves them as they are, and podman:generate fills them in:

Placeholder Value
{{application}} quadlet_prefix, kebab-cased
{{proxy}} proxy_prefix, kebab-cased
{{appEnv}} app.env
{{appName}} app.name
{{appUrl}} app.url
{{appHost}} The host part of app.url
{{appUid}}/{{appGid}} quadlet_uid/quadlet_gid
{{workingPath}} working_path
{{configPath}} config_path (the same as working_path unless set)
{{runtimePath}} The preset's rendered runtimes/ folder, e.g. podman/production/runtimes
{{systemctl}} systemctl --user, or systemctl when quadlet_uid is 0 (root)
{{ondemand}}, {{ondemandListen}}, {{ondemandPort}}, {{ondemandIdleTimeout}}, {{appUpstream}} On-demand settings

{{configPath}} is useful for keeping a service's config outside the project, for example in your own proxy.quadlets:

Volume={{configPath}}/{{proxy}}:/etc/caddy:rw,z,U

Your own placeholders

Add your own placeholders with substitutions in config/podman.php. It's plain PHP, so env() works:

'substitutions' => [
    '{{apiEndpoint}}' => env('API_ENDPOINT'),
],
Environment=API_ENDPOINT={{apiEndpoint}}

A substitution with the same name as a built-in placeholder, such as {{appHost}}, replaces it.

Services

Every preset except devcontainer and s3 includes app plus these services. Use one per category.

Category Services (default first)
Database pgsql, mariadb, mysql, mongodb
Cache/queue valkey, redis, memcached
Queue worker queue, horizon
Search typesense, meilisearch
Object storage rustfs
Mail catcher mailpit

queue runs a plain php artisan queue:work worker, which works with any queue connection. If your app uses Laravel Horizon (Redis or Valkey queues only), use horizon instead. See Replacing the queue worker with Horizon.

production also includes schedule and inertia-ssr, which run alongside the app. reverb (Laravel Reverb) is included too, but doesn't start by default. See Adding Reverb.

In production, the queue worker and Horizon start at boot, and systemd/schedule.timer runs schedule:run every minute. Install the timer with lpod install production/my-app-schedule.timer.

Swapping the database or cache

app.quadlets names its database and cache in its [Unit] section. To switch, publish the preset:

php artisan podman:publish production   # or development

Edit containers/stubs/production/quadlets/app.quadlets:

Requires={{application}}-mysql.container {{application}}-redis.container
After={{application}}-mysql.container {{application}}-redis.container

Regenerate and reinstall both:

php artisan podman:generate production
lpod install production/mysql.quadlets --replace
lpod install production/app.quadlets --replace

Then update .env (DB_CONNECTION, DB_HOST and so on) so Laravel connects to the new service. Quadlet names containers systemd-{unit}, so the host is, for example, systemd-my-app-mysql.

Replacing the queue worker with Horizon

app.quadlets starts queue alongside the app through its Wants= line. To use horizon instead, publish the preset and change queue to horizon on that line (in production, the line ends with schedule.timer):

Wants={{application}}-mailpit.container {{application}}-horizon.container {{application}}-schedule.container

Regenerate, then install horizon and reinstall app:

php artisan podman:generate production
lpod install production/horizon.quadlets --replace
lpod install production/app.quadlets --replace

If queue was already running, stop it with systemctl --user stop {app}-queue. It won't start again, because nothing wants it anymore.

Adding Reverb

reverb isn't started by default, because it needs Laravel Reverb installed in your app. To run it, publish the preset and add it to the Wants= line in app.quadlets:

Wants={{application}}-mailpit.container {{application}}-queue.container {{application}}-reverb.container {{application}}-schedule.container

Regenerate, then install reverb and reinstall app:

php artisan podman:generate production
lpod install production/reverb.quadlets --replace
lpod install production/app.quadlets --replace

How services depend on each other

Directive Meaning Used for
Requires= Hard dependency. If the target fails, this unit stops too app → database and cache
After= Start order only Together with Requires=/Wants=
Wants= Soft dependency. Tries to start the target, but doesn't fail without it app → mailpit/reverb/vite, queue/schedule in development, and queue/schedule.timer in production
PartOf= Stopping or restarting the target also stops or restarts this unit vite/inertia-ssr, and horizon/queue/schedule in development → app
BindsTo= Like Requires=, and also stops when the target stops. Not used: it counts as needing the target, which keeps an on-demand app running

Other tasks

Increasing a service's memory limit

Publish the preset, add Memory=1G under [Container] in that service's quadlet, for example containers/stubs/production/quadlets/pgsql.quadlets, then regenerate and reinstall it:

php artisan podman:generate production
lpod install production/pgsql.quadlets --replace

Volume flags

The presets only add SELinux and ownership flags to a mount where they're needed:

Mount Flags Why
Named volumes shared by the app's services (/config, /data, /cache) none Podman's SELinux policy already labels named volumes for containers. On first use, Podman also gives the volume the owner of the folder in the image. z and U would walk the whole volume on every start, and the scheduler starts a container every minute.
Your working copy (/app in development) z Host folders need a container label. The development app also has U, which gives files back to you if something changed their owner.
A volume used by one service, such as the database Z,U Z gives it a label private to that container. U hands it to the user the image runs as.

Don't use Z on a mount that several containers share: the last container to start would lock the others out. If a named volume ends up with the wrong label, for example after copying a backup into it from the host, run restorecon -R ~/.local/share/containers/storage/volumes (or /var/lib/containers/storage/volumes for system-wide installs).

Keeping more folders writable

When the production image starts, it gives /app/storage, /app/bootstrap/cache, /config, /data and /cache back to the app user if something else owns them, for example after a podman exec without --user, which runs as root. It only checks the top folder of each, so a root-owned folder deeper inside isn't fixed. If your app writes to such a folder, list it in PODMAN_CHOWN_PATHS (space-separated) under [Container] in each quadlet that runs the app image:

[Container]
Environment="PODMAN_CHOWN_PATHS=/cache/temp /cache/temp/media"

Then regenerate and reinstall the services.

Running several apps on one host

Pass --application= to lpod install (needs Podman 6 or later), so each app gets its own install folder. With on-demand services, also give each app its own ondemand.listen and ondemand.port.

Proxying services without the proxy preset

production runs Octane with FrankenPHP, which has Caddy built in. This is separate from the proxy preset's Caddy container (see Proxy).

If you don't use the proxy preset, for example behind an external load balancer or on Laravel Cloud, the built-in Caddy can proxy other services (Reverb, Mailpit, S3 storage) itself. You configure it through Octane's CADDY_EXTRA_CONFIG environment variable.

Foxws\Podman\Support\PodmanCaddySites builds that value from env vars. Use it in config/octane.php:

use Foxws\Podman\Support\PodmanCaddySites;

'caddy' => [
    'env' => [
        // Port must match the "--port" passed to "octane:frankenphp" in APP_COMMAND.
        'CADDY_EXTRA_CONFIG' => PodmanCaddySites::render([
            PodmanCaddySites::hostFromUrl((string) env('AWS_URL')) => PodmanCaddySites::hostPortFromUrl((string) env('AWS_ENDPOINT')),
            (string) env('VITE_REVERB_HOST', env('REVERB_HOST')) => PodmanCaddySites::hostPort(env('REVERB_HOST'), env('REVERB_PORT', 6001)),
            (string) env('MAILPIT_UI_HOST') => PodmanCaddySites::hostPort(env('MAIL_HOST'), 8025),
        ], (int) env('OCTANE_PORT', 8000)),
    ],
],

With AWS_URL=https://s3.laravel.test, VITE_REVERB_HOST=ws.laravel.test and MAILPIT_UI_HOST=mail.laravel.test in .env (the same subdomains the proxy preset uses), you get:

Hostname Upstream Env vars
s3.laravel.test e.g. minio:9000 AWS_URL, AWS_ENDPOINT
ws.laravel.test e.g. reverb:6001 VITE_REVERB_HOST (or REVERB_HOST), REVERB_HOST, REVERB_PORT
mail.laravel.test e.g. mailpit:8025 MAILPIT_UI_HOST, MAIL_HOST

Add or remove entries to match the services you use.

A few things to know:

  • Use env() here, not config(). Config files can't rely on each other's load order.
  • An entry with an empty hostname or upstream (unset env var) is skipped.
  • render() uses http:// by default. With a bare hostname, Caddy would try automatic HTTPS on port 443. The production image runs as a non-root user without permission to bind that port, so the server would crash. Only pass the third $scheme argument if your Caddy is allowed to bind ports below 1024.

Rendering presets from code

PodmanPresetGenerator renders a preset without Artisan, for example from an installer. It returns what it rendered:

use Foxws\Podman\Support\PodmanPresetGenerator;

$rendered = (new PodmanPresetGenerator)->generate('production');

$rendered->path;                       // where it was rendered to
$rendered->units();                    // every unit of its .quadlets files
$rendered->systemdUnitsOfType('timer'); // its plain systemd units, by type
$rendered->secrets();                  // the Podman secrets its units use, with their type and target

Each unit (PodmanQuadletUnit) has its type (a QuadletUnitType, such as QuadletUnitType::Container), its fileName() (acme.container), the serviceName() systemd generates for it (acme.service, acme-build.service) and its secrets(). $unit->type->isRunnable() is true for containers, pods and kube units, which start the rest. PodmanQuadletUnit::fromFile() splits any rendered .quadlets file the same way.

Each secret (PodmanQuadletSecret) has its name, its type (QuadletSecretType::Env for an environment variable, QuadletSecretType::Mount for a file) and its target, such as POSTGRES_PASSWORD or /app/.env. PodmanSecrets creates them with podman secret, without lpod:

use Foxws\Podman\Support\PodmanSecrets;

$secrets = new PodmanSecrets;

$secrets->exists('acme-env');
$secrets->create('acme-pgsql-password', $password); // the value goes through stdin
$secrets->createFromFile('acme-env', base_path('.env'));

It runs podman by default. Set PODMAN_BINARY=podman-remote when it runs in a container with the host's Podman socket mounted.