Laravel in containers, done right
Laravel deployments have historically meant a VPS, Forge, or shared hosting. Containerizing it is cleaner and portable, but PHP has a quirk: it's not a single long-running server like Node. You run PHP-FPM behind a web server (Nginx) and process the request lifecycle per request. Let's package that properly.
Step 1: A container with Nginx + PHP-FPM
The pragmatic approach is one container running Nginx and PHP-FPM via a process manager, or the official PHP-FPM image with a sidecar. For a single-container deploy, use a base that bundles both (FrankenPHP is increasingly popular and simplifies this dramatically):
FROM dunglas/frankenphp:1-php8.3
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --optimize-autoloader --no-interaction
COPY . .
RUN composer dump-autoload --optimize
# Cache config/routes/views at build time
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache
ENV SERVER_NAME=:8080
EXPOSE 8080
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]FrankenPHP serves on a port you control — bind it to the platform's port (here :8080). Caching config, routes, and views at build time is the single biggest production performance win in Laravel.
Step 2: Environment and the APP_KEY
Laravel needs APP_KEY set, plus the usual config. Set these as env vars in the dashboard — never commit .env:
APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:... # php artisan key:generate --show
DB_CONNECTION=mysqlAPP_DEBUG=false in production is a security requirement — debug mode leaks stack traces and env values.
Step 3: Wire a managed MySQL
Provision a managed MySQL (5.7 or 8.x) on PandaStack. Laravel reads either discrete DB_* vars or a URL. PandaStack injects a connection string for the attached database; map it to Laravel's DB_* or use DATABASE_URL parsing:
// config/database.php (Laravel supports a url key)
'mysql' => [
'url' => env('DATABASE_URL'),
'driver' => 'mysql',
// ...
],Run migrations as a release step:
php artisan migrate --forceThe --force flag is required to run migrations non-interactively in production.
Step 4: Queue workers
Laravel queues need a long-running worker process. Deploy a second container app with the worker as its start command:
php artisan queue:work --tries=3 --max-time=3600Use --max-time so workers recycle periodically (PHP isn't great at long-lived memory). Back the queue with the managed Redis for reliability:
QUEUE_CONNECTION=redis
REDIS_URL=redis://...Step 5: The scheduler as a cronjob
Laravel's scheduler is meant to be invoked every minute. Instead of a cron *inside* the container, use a PandaStack cronjob running every minute:
# Cronjob schedule: * * * * *
php artisan schedule:runThis is cleaner than baking cron into the app image and gives you separate logs for scheduled runs.
Step 6: Deploy
Connect the repo and push. The build runs in a rootless BuildKit K8s Job pod, the image lands in Artifact Registry, and Helm deploys it with live logs:
git push origin mainAdd your domain for automatic SSL. You now have the web app, a queue worker, and a scheduler cronjob all sharing one managed MySQL and Redis.
Step 7: Storage and sessions
Containers are ephemeral — don't write user uploads to local disk. Use an object store (S3-compatible) via Laravel's filesystem config, and put sessions in Redis or the database so they survive redeploys:
SESSION_DRIVER=redis
FILESYSTEM_DISK=s3Production checklist
- [ ]
config:cache,route:cache,view:cacheat build - [ ]
APP_DEBUG=false,APP_KEYset - [ ] Migrations run with
--forceas a release step - [ ] Queue worker as a separate long-running app
- [ ] Scheduler as a per-minute cronjob
- [ ] Sessions + cache in Redis, uploads in object storage
References
- [Laravel — Deployment](https://laravel.com/docs/12.x/deployment)
- [FrankenPHP documentation](https://frankenphp.dev/docs/)
- [Laravel — Queues](https://laravel.com/docs/12.x/queues)
- [Laravel — Task Scheduling](https://laravel.com/docs/12.x/scheduling)
---
Laravel maps neatly onto a modern container platform once you split web, worker, and scheduler. Run all three plus a managed MySQL on PandaStack's [free tier](https://dashboard.pandastack.io) and skip the VPS maintenance entirely.