Laravel is one of the most productive backend frameworks around, but the gap between php artisan serve and a production deployment is wide. You need a proper web server (Nginx + PHP-FPM or FrankenPHP), database migrations that run at the right time, a queue worker for jobs, and a managed database so you're not babysitting MySQL on a VM.
This tutorial walks through containerizing a Laravel app and connecting it to a managed MySQL database.
Architecture
We'll run three logical pieces:
- Web container — PHP-FPM + Nginx (or a single FrankenPHP binary) serving HTTP.
- Queue worker —
php artisan queue:workfor background jobs. - Managed MySQL — provisioned separately, connected via
DATABASE_URL/ env vars.
Step 1: Containerize Laravel
The cleanest modern option is [FrankenPHP](https://frankenphp.dev), which bundles a production web server and PHP into one binary. Here's a minimal Dockerfile:
FROM dunglas/frankenphp:1-php8.3 AS base
WORKDIR /app
# System deps for common Laravel extensions
RUN install-php-extensions pdo_mysql gd intl zip opcache pcntl
# Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Install PHP deps first for better layer caching
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# Copy app and finish autoload
COPY . .
RUN composer dump-autoload --optimize \
&& php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache
EXPOSE 8080
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]A companion Caddyfile for FrankenPHP:
{
frankenphp
auto_https off
}
:8080 {
root * /app/public
php_server
}We disable internal HTTPS because TLS is terminated at the platform's ingress.
Step 2: Provision managed MySQL
Laravel works with MySQL 5.7 and 8.x. On [PandaStack](https://dashboard.pandastack.io), create a managed MySQL database (provisioned via KubeBlocks on GKE) and pick the version your app targets. The platform exposes connection details as environment variables and injects DATABASE_URL automatically when the database is linked to your app.
Laravel can consume a single URL or discrete variables. The discrete form maps cleanly:
DB_CONNECTION=mysql
DB_HOST=<managed-host>
DB_PORT=3306
DB_DATABASE=<db-name>
DB_USERNAME=<user>
DB_PASSWORD=<password>If you prefer the URL form, Laravel 9+ supports DB_URL:
DB_URL=${DATABASE_URL}Step 3: Set the rest of your environment
Laravel needs an app key and sensible production settings:
APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:... # php artisan key:generate --show
APP_URL=https://yourapp.com
SESSION_DRIVER=database
QUEUE_CONNECTION=database
CACHE_STORE=databaseUsing database drivers for sessions/queue/cache keeps you stateless without needing Redis on day one. When you scale up, switch these to a managed Redis instance for performance.
Never commit
APP_KEY. Generate it once and store it as a secret env var in the dashboard.
Step 4: Run migrations safely
The biggest footgun in Laravel deploys is *when* migrations run. Running them in the Docker build is wrong — the database may be unreachable, and parallel build replicas would race. Run them as a release/deploy step that executes once before the new version takes traffic.
php artisan migrate --forceThe --force flag is required in production (it bypasses the interactive confirmation). On PandaStack you set this as a pre-deploy command so it runs once per release.
Step 5: Add a queue worker
Background jobs (emails, image processing, webhooks) should run in a separate process so they don't block web requests. Deploy a second container/app from the same image with a different start command:
php artisan queue:work --tries=3 --max-time=3600 --sleep=3Key flags:
--tries=3retries failed jobs before marking them failed.--max-time=3600restarts the worker hourly to avoid memory creep.--sleep=3polls every 3s when the queue is empty.
For scheduled tasks (app/Console/Kernel.php), deploy a cronjob that runs php artisan schedule:run every minute rather than keeping a long-lived scheduler process.
Step 6: Deploy
- 1Push the repo with the Dockerfile to GitHub.
- 2Create a container app on PandaStack and connect the repo — it builds the Dockerfile with rootless BuildKit and pushes to the registry.
- 3Link the managed MySQL database so
DATABASE_URLis injected. - 4Set the migrate command as a pre-deploy hook.
- 5Deploy the web app, then the queue worker app from the same image.
- 6Add your custom domain — SSL is issued automatically.
Production checklist
| Concern | Recommendation |
|---|---|
| Config caching | config:cache + route:cache in the image |
| OPcache | Enabled (huge throughput win for PHP) |
| Migrations | migrate --force as a one-time deploy step |
| Sessions/cache | database driver initially, Redis at scale |
| Storage uploads | Use object storage, not local disk (containers are ephemeral) |
| Health check | Add a /up route (Laravel 11 ships one) |
A note on ephemeral filesystems
Containers don't keep local files between deploys or restarts. Any storage/app/public uploads must go to object storage (S3-compatible) via Laravel's filesystem config. Treat the container disk as throwaway.
References
- [Laravel Deployment docs](https://laravel.com/docs/deployment)
- [FrankenPHP documentation](https://frankenphp.dev/docs/)
- [Laravel Queues](https://laravel.com/docs/queues)
- [Laravel Database configuration](https://laravel.com/docs/database)
- [MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/)
---
Managed MySQL takes the scariest part of a Laravel deploy off your plate — no patching, scheduled and manual backups, and a connection string injected straight into your app. PandaStack's free tier includes one managed database and enough build minutes to get going; start at [dashboard.pandastack.io](https://dashboard.pandastack.io).