Laravel + Kubernetes: Deploying Scalable PHP Applications from Scratch in 2026
Introduction: Why Kubernetes Has Become the Deployment Standard for PHP Applications in 2026
If Kubernetes was seen as a tool for large enterprises back in 2020, by 2026 it has become the de facto standard for any PHP application that needs to handle load and scale easily. Managed clusters from GKE, EKS, and AKS have become cheaper and simpler to configure. The Kubernetes ecosystem — Helm, Argo CD, Kustomize — has reached maturity. And most importantly: Laravel as a framework has become significantly better adapted to cloud-native environments thanks to Octane, native Redis queue support, and horizontal worker scaling.
In this article, we'll walk through the entire journey: from preparing a Laravel application for stateless architecture to writing production-ready Kubernetes manifests, configuring HPA, and managing migrations. The target audience is mid and senior PHP developers and DevOps engineers who already work with Docker and want to move to Kubernetes.
1. Preparing a Laravel Application for Kubernetes
Stateless Architecture
Kubernetes assumes that pods can be recreated at any time. This means your Laravel application must not store state locally. Check the following points:
- Sessions — switch from the
filedriver toredisordatabase. - Cache — use Redis or Memcached instead of file-based cache.
- Queues — use Redis or Amazon SQS, not the sync driver.
- File storage — use an S3-compatible storage solution (AWS S3, MinIO), not the local disk.
Environment Variables
In Kubernetes, configuration is passed via ConfigMap and Secret rather than through a .env file. Make sure your application reads all settings via env() and config(), not from hardcoded values. The .env file must not be included in the Docker image.
Health Checks
Kubernetes requires endpoints to check the health of containers. Add a simple health check route:
// routes/web.php\nRoute::get('/healthz', function () {\n return response()->json(['status' => 'ok']);\n});For deeper checks (database connection, Redis), use the spatie/laravel-health package or write a custom controller that verifies all dependencies and returns HTTP 200 or 503.
2. Building a Docker Image for Laravel with Multi-Stage Builds
A multi-stage build produces a compact production image without dev dependencies and build tools. Below is a working Dockerfile for Laravel in 2026:
# ---- Stage 1: Node build (for asset compilation) ----\nFROM node:20-alpine AS node-builder\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\n# ---- Stage 2: Composer dependencies ----\nFROM composer:2.7 AS composer-builder\nWORKDIR /app\nCOPY composer.json composer.lock ./\nRUN composer install \\\n --no-dev \\\n --no-interaction \\\n --prefer-dist \\\n --optimize-autoloader\n\n# ---- Stage 3: Production image ----\nFROM php:8.3-fpm-alpine\n\n# System dependencies\nRUN apk add --no-cache \\\n nginx \\\n supervisor \\\n libpq-dev \\\n libzip-dev \\\n oniguruma-dev \\\n && docker-php-ext-install \\\n pdo_pgsql \\\n pdo_mysql \\\n zip \\\n opcache \\\n pcntl\n\n# Copy application\nWORKDIR /var/www/html\nCOPY --from=composer-builder /app/vendor ./vendor\nCOPY --from=node-builder /app/public/build ./public/build\nCOPY . .\n\n# File permissions\nRUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache\n\n# Laravel optimization\nRUN php artisan config:cache \\\n && php artisan route:cache \\\n && php artisan view:cache\n\nEXPOSE 9000\nCMD [\"php-fpm\"]Note: running config:cache during the build stage only makes sense if environment variables do not change between environments. In most cases, it is better to run caching in an entrypoint script after injecting variables from Kubernetes.
3. Writing Kubernetes Manifests
ConfigMap
Non-sensitive environment variables go into a ConfigMap:
apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: laravel-config\n namespace: production\ndata:\n APP_ENV: production\n APP_DEBUG: "false"\n LOG_CHANNEL: stderr\n CACHE_DRIVER: redis\n SESSION_DRIVER: redis\n QUEUE_CONNECTION: redis\n REDIS_HOST: redis-service\n DB_CONNECTION: pgsql\n DB_HOST: postgres-service\n DB_PORT: "5432"\n DB_DATABASE: laravel_dbSecret
Sensitive values (passwords, keys) go into a Kubernetes Secret (in production, use External Secrets Operator or Vault):
apiVersion: v1\nkind: Secret\nmetadata:\n name: laravel-secrets\n namespace: production\ntype: Opaque\nstringData:\n APP_KEY: base64:YOUR_KEY\n DB_PASSWORD: YOUR_PASSWORD\n REDIS_PASSWORD: YOUR_REDIS_PASSWORDDeployment
apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: laravel-app\n namespace: production\nspec:\n replicas: 3\n selector:\n matchLabels:\n app: laravel-app\n template:\n metadata:\n labels:\n app: laravel-app\n spec:\n containers:\n - name: php-fpm\n image: your-registry/laravel-app:1.2.3\n ports:\n - containerPort: 9000\n envFrom:\n - configMapRef:\n name: laravel-config\n - secretRef:\n name: laravel-secrets\n resources:\n requests:\n cpu: 250m\n memory: 256Mi\n limits:\n cpu: 1000m\n memory: 512Mi\n livenessProbe:\n httpGet:\n path: /healthz\n port: 8080\n initialDelaySeconds: 10\n periodSeconds: 15\n readinessProbe:\n httpGet:\n path: /healthz\n port: 8080\n initialDelaySeconds: 5\n periodSeconds: 10Service and Ingress
apiVersion: v1\nkind: Service\nmetadata:\n name: laravel-service\n namespace: production\nspec:\n selector:\n app: laravel-app\n ports:\n - port: 80\n targetPort: 8080\n---\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n name: laravel-ingress\n namespace: production\n annotations:\n nginx.ingress.kubernetes.io/proxy-body-size: "50m"\n cert-manager.io/cluster-issuer: letsencrypt-prod\nspec:\n ingressClassName: nginx\n tls:\n - hosts:\n - yourdomain.com\n secretName: laravel-tls\n rules:\n - host: yourdomain.com\n http:\n paths:\n - path: /\n pathType: Prefix\n backend:\n service:\n name: laravel-service\n port:\n number: 804. Horizontal Scaling (HPA) for PHP Workers and Queues
HPA (Horizontal Pod Autoscaler) automatically adjusts the number of replicas based on load. For a Laravel application, HPA is typically configured by CPU, while Queue Workers are scaled by queue length using KEDA (Kubernetes Event-Driven Autoscaling).
CPU-Based HPA for the Web Application
apiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nmetadata:\n name: laravel-hpa\n namespace: production\nspec:\n scaleTargetRef:\n apiVersion: apps/v1\n kind: Deployment\n name: laravel-app\n minReplicas: 3\n maxReplicas: 20\n metrics:\n - type: Resource\n resource:\n name: cpu\n target:\n type: Utilization\n averageUtilization: 60Queue Workers with KEDA
To scale Laravel Queue workers, use KEDA with a ScaledObject that reacts to queue length in Redis:
apiVersion: keda.sh/v1alpha1\nkind: ScaledObject\nmetadata:\n name: laravel-queue-scaler\n namespace: production\nspec:\n scaleTargetRef:\n name: laravel-queue-worker\n minReplicaCount: 1\n maxReplicaCount: 10\n triggers:\n - type: redis\n metadata:\n address: redis-service:6379\n listName: queues:default\n listLength: "50"The Queue Worker Deployment should use the php artisan queue:work command with the --max-time or --max-jobs flag so that the worker restarts gracefully during updates.
5. Managing Database Migrations in Kubernetes
Running php artisan migrate in Kubernetes is non-trivial. There are two main approaches:
Kubernetes Job
A Job runs once and exits. It is suitable for CD pipelines where migrations run before the Deployment is updated:
apiVersion: batch/v1\nkind: Job\nmetadata:\n name: laravel-migrate\n namespace: production\nspec:\n template:\n spec:\n restartPolicy: Never\n containers:\n - name: migrate\n image: your-registry/laravel-app:1.2.3\n command: ["php", "artisan", "migrate", "--force"]\n envFrom:\n - configMapRef:\n name: laravel-config\n - secretRef:\n name: laravel-secretsInit Container
An Init Container runs before the main container of a pod. It is convenient for automatically applying migrations on deploy:
initContainers:\n - name: migrate\n image: your-registry/laravel-app:1.2.3\n command: ["php", "artisan", "migrate", "--force"]\n envFrom:\n - configMapRef:\n name: laravel-config\n - secretRef:\n name: laravel-secretsImportant: when using an Init Container, the migration will run for every pod. To avoid conflicts during parallel deployment of multiple replicas, ensure your migrations are idempotent, or use a Job with a PreSync Hook in Argo CD.
6. Monitoring a Laravel Application in a Cluster
For comprehensive observability of a PHP application in Kubernetes, use the following stack:
- Prometheus + Grafana — collecting cluster and application metrics. Use the
spatie/laravel-prometheuspackage or expose metrics via a/metricsendpoint. - Loki — log aggregation. Configure Laravel to output logs to
stderr(LOG_CHANNEL=stderr), and Loki will collect them automatically via Promtail. - OpenTelemetry — distributed request tracing. The
open-telemetry/opentelemetry-phppackage integrates with Laravel and sends traces to Jaeger or Tempo. - Kubernetes Events + Alertmanager — alerts for pod crashes, OOMKills, and liveness probe failures.
Set up a Grafana dashboard with key metrics: RPS, p95/p99 latency, 5xx error rate, PHP-FPM memory usage, and Redis queue length.
7. Common Mistakes and How to Avoid Them
- Storing sessions in files — when a pod restarts, users lose their sessions. Solution: use the Redis driver.
- APP_KEY not set or different across replicas — data encrypted in one pod cannot be decrypted in another. Solution: use a single Secret with a shared APP_KEY.
- config:cache during image build — the cached config contains values from the build environment, not from production. Solution: cache the config in the entrypoint script or skip caching altogether.
- No resource limits — a single pod can consume all node resources. Always define
requestsandlimits. - Queue workers without graceful shutdown — during a deployment update, a worker may be killed mid-job. Solution: set
terminationGracePeriodSeconds: 60and use the--stop-when-emptyflag. - No readiness probe — traffic is routed to a pod that is not yet ready. Always configure readinessProbe separately from livenessProbe.
- Images without pinned tags — using the
latesttag makes deployments unpredictable. Always tag images with a specific hash or version.
Conclusion and Final Recommendations
Deploying Laravel on Kubernetes in 2026 is not difficult if you approach it systematically. Here is a concise checklist for a production-ready deployment:
- Move to a stateless architecture: Redis for sessions/cache/queues, S3 for files.
- Build a compact Docker image using a multi-stage build based on PHP 8.3 FPM Alpine.
- Split configuration into ConfigMap (non-sensitive) and Secret (passwords, keys).
- Configure livenessProbe and readinessProbe for all containers.
- Set resource requests and limits for predictable pod scheduling.
- Use HPA for web replicas and KEDA for queue workers.
- Run migrations via a Job (in CI/CD) or an Init Container with idempotent migrations.
- Stream logs to stderr, expose metrics via Prometheus, and set up tracing with OpenTelemetry.
- Use Argo CD or Flux for GitOps-based deployments — no manual
kubectl applyin production.
Kubernetes gives Laravel applications horizontal scalability, self-healing, and predictable deployments. The investment in proper initial setup pays off the first time you experience a traffic spike. Start with a local cluster using kind or k3d, migrate one microservice or Laravel monolith, and gradually build out a full production pipeline.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →