**Tags:** "Hello World" Examples · PHP · Laravel

# Laravel showcase

A [Laravel](https://laravel.com) application connected to [PostgreSQL](https://www.postgresql.org/), [Valkey](https://valkey.io/) (Redis-compatible), S3-compatible object storage, and [Meilisearch](https://www.meilisearch.com/), running on [Zerops](https://zerops.io) with six ready-made environment configurations — from AI agent and remote development to stage and highly-available production.

### Available Environments

- [AI Agent](https://app.zerops.io/recipes/laravel-showcase.md?environment=ai-agent)
- [Remote (CDE)](https://app.zerops.io/recipes/laravel-showcase.md?environment=remote-cde)
- [Local](https://app.zerops.io/recipes/laravel-showcase.md?environment=local)
- [Stage](https://app.zerops.io/recipes/laravel-showcase.md?environment=stage)
- **Small Production** ← current
- [Highly-available Production](https://app.zerops.io/recipes/laravel-showcase.md?environment=highly-available-production)

### Services in this Environment

**Services:**

- **core** (core@1)
  - Containers: 1 × Shared Core, 0.00 GB RAM, 0 GB Disk
- **app** (php-nginx@8.4)
  - Containers: 2 × Shared Core, 0.75 GB RAM, 1 GB Disk
  - Repository: [zerops-recipe-apps/laravel-showcase-app](https://github.com/zerops-recipe-apps/laravel-showcase-app)
- **worker** (php-nginx@8.4)
  - Containers: 2 × Shared Core, 0.75 GB RAM, 1 GB Disk
  - Repository: [zerops-recipe-apps/laravel-showcase-app](https://github.com/zerops-recipe-apps/laravel-showcase-app)
- **db** (postgresql@18) :5432, :6432
  - Containers: 1 × Shared Core, 0.50 GB RAM, 1 GB Disk
- **redis** (valkey@7.2) :6379, :6380
  - Containers: 1 × Shared Core, 0.50 GB RAM, 1 GB Disk
- **storage** (object-storage)
  - Containers: 1 × Shared Core, 0.00 GB RAM, 0 GB Disk
- **search** (meilisearch@1.20) :7700
  - Containers: 1 × Shared Core, 0.50 GB RAM, 1 GB Disk

**Total Resources:** 9 containers, 4.50 GB RAM, 7 GB Disk

### One-Click Deploy (Import YAML)

Use this YAML with `zcli project import` to deploy this environment:

```yaml
#zeropsPreprocessor=on

# Small production environment offers a production-ready setup optimized for
# moderate throughput.

# APP_KEY — production encryption key shared across all app and worker
# containers. Critical for session validity when the L7 balancer distributes
# requests across multiple containers.
project:
  name: laravel-showcase-small-prod
  envVariables:
    APP_KEY: <@generateRandomString(<32>)>

services:
  # Small production — minContainers: 2 guarantees two app containers at all
  # times, enabling rolling deploys with zero downtime (one container serves
  # traffic while the other rebuilds). Zerops autoscales RAM within
  # verticalAutoscaling bounds to absorb traffic spikes without manual
  # intervention.
  - hostname: app
    type: php-nginx@8.4
    zeropsSetup: prod
    buildFromGit: https://github.com/zerops-recipe-apps/laravel-showcase-app
    enableSubdomainAccess: true
    minContainers: 2
    verticalAutoscaling:
      minRam: 0.5
      minFreeRamGB: 0.25

  # Production queue worker — processes background jobs with --tries=3 retry
  # policy. Single container sufficient for moderate job volumes; scale
  # minContainers if queue depth grows.
  - hostname: worker
    type: php-nginx@8.4
    zeropsSetup: worker
    buildFromGit: https://github.com/zerops-recipe-apps/laravel-showcase-app
    minContainers: 2
    verticalAutoscaling:
      minRam: 0.5
      minFreeRamGB: 0.25

  # PostgreSQL — production database with NON_HA. Adequate for small
  # production workloads; automated backups provide data safety.
  - hostname: db
    type: postgresql@18
    priority: 10
    mode: NON_HA
    verticalAutoscaling:
      minRam: 0.25
      minFreeRamGB: 0.25

  # Valkey — production cache, session, and queue backend. In-memory latency
  # keeps session reads and cache lookups fast under production load.
  - hostname: redis
    type: valkey@7.2
    priority: 10
    mode: NON_HA
    verticalAutoscaling:
      minRam: 0.25
      minFreeRamGB: 0.25

  # S3-compatible object storage — production file storage with private access
  # policy.
  - hostname: storage
    type: object-storage
    priority: 10
    objectStorageSize: 1
    objectStoragePolicy: private

  # Meilisearch — production search index. scout:import in initCommands
  # ensures the index stays populated after every deploy.
  - hostname: search
    type: meilisearch@1.20
    priority: 10
    mode: NON_HA
    verticalAutoscaling:
      minRam: 0.25
      minFreeRamGB: 0.25


```

---

## Next Steps

After deploying one of the environments and getting to know Zerops, you have two paths to choose from:

1. **Template Flow** — Clone our GitHub repositories and use the whole recipe as a template
2. **Integrate Flow** — If you already have an existing application on a similar stack, integrate the recipe setup with your application

Select a flow: [Template Flow](https://app.zerops.io/recipes/laravel-showcase.md?environment=small-production&guideFlow=template) or [Integrate Flow](https://app.zerops.io/recipes/laravel-showcase.md?environment=small-production&guideFlow=integrate)

Both flows are shown below:

## How to take over the Small Production environment

### 📦 Clone the template repositories

Fork or clone the following repositories to your local machine or GitHub account:

- [zerops-recipe-apps/laravel-showcase-app](https://github.com/zerops-recipe-apps/laravel-showcase-app)
- [zerops-recipe-apps/laravel-showcase-app](https://github.com/zerops-recipe-apps/laravel-showcase-app)

### 1. Find your service name

Many commands and configurations need the exact name of your service. You can find it in the Zerops Dashboard.

- Open your project in the Zerops Dashboard.
- In the project overview, find the service you want to manage.
- Use this exact name whenever a command or pipeline configuration asks for `<service-name>`.

<img src="https://storage-prg1.zerops.io/4gfos-storage/copy1_cd2a6044c8.jpg" style="display: block; margin: 0 auto;" alt="Zerops GUI: Locating the Service Name" width="500" />

### 2. Configure deployment pipeline

Go to Service Settings > Pipelines & CI/CD Settings in the Zerops Dashboard and connect your repository.

For production, use a trigger on new tags. This keeps deployments intentional and tied to a specific version. You can also add a regex filter, such as `^v[0-9]+\.[0-9]+\.[0-9]+$`, if you want to allow only semantic version tags.

<img src="https://storage-prg1.zerops.io/4gfos-storage/triggerborder_b865860a89.jpg" style="display: block; margin: 0 auto;" alt="Zerops GUI: Triggers" width="500" />

Alternatively, add `zcli push` to your existing CI/CD pipeline if you want full control over when deployments happen.

Learn more about pipeline triggers: https://docs.zerops.io/features/pipeline

### 3. Deploy to production

Create and push a new Git tag to deploy a specific version of your app:

```bash
git tag -a v1.0.0 -m "Release version 1.0.0"
git push origin v1.0.0
```

> [!TIP]
> Open the pipeline detail in the Zerops Dashboard to check the build progress and verify that all steps finish successfully.

### 4. Configure autoscaling

Review the autoscaling settings for your runtime services and databases in Service Settings > Automatic Scaling Configuration in the Zerops Dashboard.

<img src="https://storage-prg1.zerops.io/4gfos-storage/scaling_ac0880aef5.png" style="display: block; margin: 0 auto;" alt="Zerops GUI: Autoscaling configuration" width="500" />

The most important settings are:

```yaml
verticalAutoscaling:
  minRam: 1
  minFreeRamGB: 0.5
  minFreeRamPercent: 20
```

> [!CAUTION]
> Pay attention to `minFreeRamGB`. This value tells Zerops when to scale RAM vertically. Adjust it based on your app’s real memory needs. RAM scales up immediately, while CPU scales after two consecutive measurements below the threshold.

> [!TIP]
> Run a quick stress test with a tool like hey before real users arrive. This helps you see how your app behaves under load and tune the autoscaling settings.

### 5. Set up your domain

To send real traffic to your app, configure public HTTP access in Service Settings > Public Access & Internal Ports in the Zerops Dashboard.

Add your custom domain and point your DNS records to the Zerops IPs shown in the dashboard:

<img src="https://storage-prg1.zerops.io/4gfos-storage/subdomain_8cafd801e8.jpg" style="display: block; margin: 0 auto;" alt="Zerops GUI: Public access and custom domain" width="500" />

```text
Type   Name          Content          TTL
A      example.com   <zerops-ipv4>    Auto
AAAA   example.com   <project-ipv6>   Auto
```

For wildcard domains, add a CNAME record for SSL validation.

Check the public access documentation: https://docs.zerops.io/features/access

> [!TIP]
> When changing DNS records for production, start with a low TTL value. Make sure SSL certificates are active before you disable the fallback Zerops subdomain.

Once everything works, you can disable the Zerops subdomain so all traffic goes through your custom domain.

---

### 🎉 You are good to go!

Your application is live in production and the core setup is complete.

The following sections are optional. They cover extra production features such as log forwarding, backups, and diagnostic access. You can stop here and come back later when you need them.

---

### 6. Set up log forwarding (Optional)

To send logs to an external service, go to Project Settings > Log Forwarding & Logs Overview in the Zerops Dashboard.

You can forward logs to services like Better Stack, Papertrail, or your own self-hosted solution.

Learn more about log forwarding: https://docs.zerops.io/references/logging

### 7. Configure database backups (Optional)

Manage automated encrypted backups in Service Settings > Backups in the Zerops Dashboard.

By default, backups run daily between 00:00 and 01:00 UTC.

Before a major deployment, create a manual protected backup:

```bash
zcli backup create <db-service> --tags pre-deploy,protected
```

Read the backup documentation for more options: https://docs.zerops.io/features/backup

### 8. Set up diagnostic access (Optional)

Use zCLI and VPN access when you need to inspect or maintain services directly.

For runtime services:

```bash
zcli vpn up
ssh <service-name>.zerops
```

For databases, connect through the VPN to reach the project’s private network, or set up secure direct IP access for your database admin tools.

Check the VPN documentation: https://docs.zerops.io/references/cli/commands#vpn-up

## How to integrate app with Zerops

### 1. Adding `zerops.yaml`

The main configuration file — place at repository root. It tells Zerops how to build, deploy and run your app.

```yaml
zerops:
  # Production — optimized build, compiled assets, framework caches,
  # full service connectivity (DB, Redis, S3, Meilisearch).
  - setup: prod
    build:
      # Multi-base build: PHP for Composer, Node for Vite asset
      # compilation. Both runtimes are fully available on PATH
      # during the build — no manual install needed.
      base:
        - php@8.4
        - nodejs@22
      buildCommands:
        # Production Composer install — no dev packages, classmap
        # optimized for faster autoloading in production.
        - composer install --no-dev --optimize-autoloader
        # Vite compiles Tailwind CSS and JS into content-hashed
        # bundles in public/build/. These static assets are all
        # the runtime container needs from the Node side.
        - npm install
        - npm run build
      deployFiles:
        # List each directory explicitly — deploying ./ would
        # ship node_modules, .env.example, and other build-only
        # artifacts the runtime container doesn't need.
        - app
        - bootstrap
        - config
        - database
        - public
        - resources/views
        - routes
        - storage
        - vendor
        - artisan
        - composer.json
      # Cache vendor/ and node_modules/ between builds so
      # Composer and npm skip redundant network fetches.
      cache:
        - vendor
        - node_modules

    # Readiness check gates the traffic switch — new containers
    # must answer HTTP 200 before the L7 balancer routes to them.
    # This enables zero-downtime deploys.
    deploy:
      readinessCheck:
        httpGet:
          port: 80
          path: /health

    run:
      # php-nginx serves via Nginx + PHP-FPM — no explicit start
      # command needed; the base image handles both processes.
      base: php-nginx@8.4
      # Nginx serves static files from public/ and proxies PHP
      # requests to FPM. Laravel expects this as its web root.
      documentRoot: public
      # Config, route, and view caches MUST be built at runtime.
      # Build runs at /build/source/ but the app serves from
      # /var/www/ — caching during build bakes wrong paths.
      #
      # Migrations run exactly once per deploy via zsc execOnce,
      # regardless of how many containers start in parallel.
      # Seeder populates sample data on first deploy so the
      # dashboard shows real records immediately.
      # Scout import rebuilds the Meilisearch index from DB data
      # after seeding — the safety net for when auto-indexing
      # fires zero events (records already exist from prior deploy).
      initCommands:
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan migrate --force
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan db:seed --force
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan scout:import "App\\Models\\Article"
        - php artisan config:cache
        - php artisan route:cache
        - php artisan view:cache
      # Health check restarts unresponsive containers after the
      # 5-minute retry window expires — keeps production alive.
      healthCheck:
        httpGet:
          port: 80
          path: /health
      envVariables:
        APP_NAME: "Laravel Zerops"
        # Production mode — stack traces hidden, error pages
        # generic, optimizations enabled.
        APP_ENV: production
        APP_DEBUG: "false"
        # APP_URL drives absolute URL generation for redirects,
        # signed URLs, mail links, and CSRF origin validation.
        # zeropsSubdomain is the platform-injected HTTPS URL.
        APP_URL: ${zeropsSubdomain}
        # syslog routes Laravel logs to the Zerops runtime log
        # viewer via the local0 facility — no log files to
        # manage or rotate, and app logs stay tagged separately
        # from PHP-FPM/nginx system messages.
        LOG_CHANNEL: syslog
        LOG_SYSLOG_FACILITY: local0
        LOG_LEVEL: warning
        # Cross-service references resolve at deploy time.
        # Pattern: ${hostname_varname} maps to the db service's
        # auto-generated credentials.
        DB_CONNECTION: pgsql
        DB_HOST: ${db_hostname}
        DB_PORT: ${db_port}
        DB_DATABASE: ${db_dbName}
        DB_USERNAME: ${db_user}
        DB_PASSWORD: ${db_password}
        # Valkey (Redis-compatible) for cache, sessions, and
        # queues — single service handles all three concerns.
        # predis client is a pure-PHP Redis client that needs
        # no compiled extension.
        REDIS_CLIENT: predis
        REDIS_HOST: ${redis_hostname}
        REDIS_PORT: ${redis_port}
        SESSION_DRIVER: redis
        CACHE_STORE: redis
        QUEUE_CONNECTION: redis
        # S3-compatible object storage backed by MinIO.
        # forcePathStyle is mandatory — MinIO does not support
        # virtual-hosted bucket addressing.
        FILESYSTEM_DISK: s3
        AWS_ACCESS_KEY_ID: ${storage_accessKeyId}
        AWS_SECRET_ACCESS_KEY: ${storage_secretAccessKey}
        AWS_DEFAULT_REGION: us-east-1
        AWS_BUCKET: ${storage_bucketName}
        AWS_ENDPOINT: ${storage_apiUrl}
        AWS_USE_PATH_STYLE_ENDPOINT: "true"
        # Meilisearch for full-text search via Laravel Scout.
        # The host uses internal HTTP — SSL is terminated at
        # the L7 balancer, not between services.
        SCOUT_DRIVER: meilisearch
        MEILISEARCH_HOST: http://${search_hostname}:${search_port}
        MEILISEARCH_KEY: ${search_masterKey}
        # Mail set to log driver — no external SMTP configured.
        # Replace with real SMTP credentials for production use.
        MAIL_MAILER: log

  # Dev — full source deployed for live editing via SSHFS.
  # PHP-FPM serves requests immediately; edit files in /var/www
  # and changes take effect on the next request — no restart.
  - setup: dev
    build:
      # Same multi-base as prod — both PHP and Node available
      # during the build so Composer and npm can run.
      base:
        - php@8.4
        - nodejs@22
      buildCommands:
        # Full Composer install with dev packages — testing and
        # debugging tools available over SSH.
        - composer install
        # Pre-populate node_modules so the developer can run
        # npm run dev (Vite HMR) immediately after SSH-ing in
        # without waiting for another install.
        - npm install
      # Deploy the entire working directory — source files,
      # vendor/, node_modules/, and zerops.yaml so zcli push
      # works from the dev container.
      deployFiles:
        - ./
      cache:
        - vendor
        - node_modules

    run:
      base: php-nginx@8.4
      documentRoot: public
      # Install Node on the runtime container so the developer
      # can run Vite dev server (npm run dev) over SSH. This
      # runs once and is cached into the runtime image — not
      # re-executed on every container restart.
      prepareCommands:
        - sudo -E zsc install nodejs@22
      # Migration + seed runs once per deploy — DB is ready
      # when the SSH session starts. No cache warming in dev
      # — we want config changes to take effect immediately.
      initCommands:
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan migrate --force
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan db:seed --force
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan scout:import "App\\Models\\Article"
      envVariables:
        APP_NAME: "Laravel Zerops"
        # Dev mode — detailed error pages with stack traces,
        # no config caching, verbose logging for debugging.
        APP_ENV: local
        APP_DEBUG: "true"
        APP_URL: ${zeropsSubdomain}
        # Debug-level syslog logging surfaces all framework
        # events in the Zerops log viewer via local0 facility.
        LOG_CHANNEL: syslog
        LOG_SYSLOG_FACILITY: local0
        LOG_LEVEL: debug
        # Same service wiring as prod — only mode flags differ.
        DB_CONNECTION: pgsql
        DB_HOST: ${db_hostname}
        DB_PORT: ${db_port}
        DB_DATABASE: ${db_dbName}
        DB_USERNAME: ${db_user}
        DB_PASSWORD: ${db_password}
        REDIS_CLIENT: predis
        REDIS_HOST: ${redis_hostname}
        REDIS_PORT: ${redis_port}
        SESSION_DRIVER: redis
        CACHE_STORE: redis
        QUEUE_CONNECTION: redis
        FILESYSTEM_DISK: s3
        AWS_ACCESS_KEY_ID: ${storage_accessKeyId}
        AWS_SECRET_ACCESS_KEY: ${storage_secretAccessKey}
        AWS_DEFAULT_REGION: us-east-1
        AWS_BUCKET: ${storage_bucketName}
        AWS_ENDPOINT: ${storage_apiUrl}
        AWS_USE_PATH_STYLE_ENDPOINT: "true"
        SCOUT_DRIVER: meilisearch
        MEILISEARCH_HOST: http://${search_hostname}:${search_port}
        MEILISEARCH_KEY: ${search_masterKey}
        MAIL_MAILER: log

  # Worker — background job processor consuming from Redis queue.
  # Same codebase as the app, different entry point. No HTTP
  # traffic — no healthCheck, readinessCheck, or documentRoot.
  - setup: worker
    build:
      # Worker only needs PHP — no asset compilation. The queue
      # runner processes jobs, not HTTP requests with CSS/JS.
      base:
        - php@8.4
      buildCommands:
        - composer install --no-dev --optimize-autoloader
      deployFiles:
        - app
        - bootstrap
        - config
        - database
        - public
        - resources/views
        - routes
        - storage
        - vendor
        - artisan
        - composer.json
      cache:
        - vendor

    run:
      # php-nginx base provides the PHP runtime. The queue:work
      # command runs as the foreground process instead of FPM.
      base: php-nginx@8.4
      # artisan queue:work processes jobs from the Redis queue.
      # --sleep=3 polls every 3s when idle, --tries=3 retries
      # failed jobs before marking them as permanently failed.
      start: php artisan queue:work --sleep=3 --tries=3
      # Cache framework config on every container start so the
      # worker resolves env vars and service references correctly.
      initCommands:
        - php artisan config:cache
      envVariables:
        APP_NAME: "Laravel Zerops"
        APP_ENV: production
        APP_DEBUG: "false"
        APP_URL: ${zeropsSubdomain}
        LOG_CHANNEL: syslog
        LOG_SYSLOG_FACILITY: local0
        LOG_LEVEL: warning
        DB_CONNECTION: pgsql
        DB_HOST: ${db_hostname}
        DB_PORT: ${db_port}
        DB_DATABASE: ${db_dbName}
        DB_USERNAME: ${db_user}
        DB_PASSWORD: ${db_password}
        REDIS_CLIENT: predis
        REDIS_HOST: ${redis_hostname}
        REDIS_PORT: ${redis_port}
        # Worker shares the same Redis-backed drivers as the app.
        # Sessions are configured but unused by the CLI process.
        SESSION_DRIVER: redis
        CACHE_STORE: redis
        QUEUE_CONNECTION: redis
        FILESYSTEM_DISK: s3
        AWS_ACCESS_KEY_ID: ${storage_accessKeyId}
        AWS_SECRET_ACCESS_KEY: ${storage_secretAccessKey}
        AWS_DEFAULT_REGION: us-east-1
        AWS_BUCKET: ${storage_bucketName}
        AWS_ENDPOINT: ${storage_apiUrl}
        AWS_USE_PATH_STYLE_ENDPOINT: "true"
        SCOUT_DRIVER: meilisearch
        MEILISEARCH_HOST: http://${search_hostname}:${search_port}
        MEILISEARCH_KEY: ${search_masterKey}
        MAIL_MAILER: log
```

### 2. Trust the reverse proxy

Zerops terminates SSL at its L7 balancer and forwards requests via reverse proxy. Without trusting the proxy, Laravel rejects CSRF tokens and generates `http://` URLs instead of `https://`. In `bootstrap/app.php`:

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: '*');
})
```

### 3. Configure Redis client

Laravel defaults to the `phpredis` C extension. On Zerops, the `predis` pure-PHP client avoids needing a compiled extension. Install via Composer and set `REDIS_CLIENT=predis` in your environment:

```bash
composer require predis/predis
```

### 4. Configure S3 object storage

Install the S3 Flysystem adapter and set `FILESYSTEM_DISK=s3` with the Zerops object storage credentials. Path-style endpoints are mandatory for the MinIO-backed storage:

```bash
composer require league/flysystem-aws-s3-v3
```

### 5. Configure Meilisearch search

Install Laravel Scout with the Meilisearch driver for full-text search. Add the `Searchable` trait to models you want indexed:

```bash
composer require laravel/scout meilisearch/meilisearch-php
```

## How to integrate worker with Zerops

### 1. Adding `zerops.yaml`

The main configuration file — place at repository root. It tells Zerops how to build, deploy and run your app.

```yaml
zerops:
  # Production — optimized build, compiled assets, framework caches,
  # full service connectivity (DB, Redis, S3, Meilisearch).
  - setup: prod
    build:
      # Multi-base build: PHP for Composer, Node for Vite asset
      # compilation. Both runtimes are fully available on PATH
      # during the build — no manual install needed.
      base:
        - php@8.4
        - nodejs@22
      buildCommands:
        # Production Composer install — no dev packages, classmap
        # optimized for faster autoloading in production.
        - composer install --no-dev --optimize-autoloader
        # Vite compiles Tailwind CSS and JS into content-hashed
        # bundles in public/build/. These static assets are all
        # the runtime container needs from the Node side.
        - npm install
        - npm run build
      deployFiles:
        # List each directory explicitly — deploying ./ would
        # ship node_modules, .env.example, and other build-only
        # artifacts the runtime container doesn't need.
        - app
        - bootstrap
        - config
        - database
        - public
        - resources/views
        - routes
        - storage
        - vendor
        - artisan
        - composer.json
      # Cache vendor/ and node_modules/ between builds so
      # Composer and npm skip redundant network fetches.
      cache:
        - vendor
        - node_modules

    # Readiness check gates the traffic switch — new containers
    # must answer HTTP 200 before the L7 balancer routes to them.
    # This enables zero-downtime deploys.
    deploy:
      readinessCheck:
        httpGet:
          port: 80
          path: /health

    run:
      # php-nginx serves via Nginx + PHP-FPM — no explicit start
      # command needed; the base image handles both processes.
      base: php-nginx@8.4
      # Nginx serves static files from public/ and proxies PHP
      # requests to FPM. Laravel expects this as its web root.
      documentRoot: public
      # Config, route, and view caches MUST be built at runtime.
      # Build runs at /build/source/ but the app serves from
      # /var/www/ — caching during build bakes wrong paths.
      #
      # Migrations run exactly once per deploy via zsc execOnce,
      # regardless of how many containers start in parallel.
      # Seeder populates sample data on first deploy so the
      # dashboard shows real records immediately.
      # Scout import rebuilds the Meilisearch index from DB data
      # after seeding — the safety net for when auto-indexing
      # fires zero events (records already exist from prior deploy).
      initCommands:
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan migrate --force
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan db:seed --force
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan scout:import "App\\Models\\Article"
        - php artisan config:cache
        - php artisan route:cache
        - php artisan view:cache
      # Health check restarts unresponsive containers after the
      # 5-minute retry window expires — keeps production alive.
      healthCheck:
        httpGet:
          port: 80
          path: /health
      envVariables:
        APP_NAME: "Laravel Zerops"
        # Production mode — stack traces hidden, error pages
        # generic, optimizations enabled.
        APP_ENV: production
        APP_DEBUG: "false"
        # APP_URL drives absolute URL generation for redirects,
        # signed URLs, mail links, and CSRF origin validation.
        # zeropsSubdomain is the platform-injected HTTPS URL.
        APP_URL: ${zeropsSubdomain}
        # syslog routes Laravel logs to the Zerops runtime log
        # viewer via the local0 facility — no log files to
        # manage or rotate, and app logs stay tagged separately
        # from PHP-FPM/nginx system messages.
        LOG_CHANNEL: syslog
        LOG_SYSLOG_FACILITY: local0
        LOG_LEVEL: warning
        # Cross-service references resolve at deploy time.
        # Pattern: ${hostname_varname} maps to the db service's
        # auto-generated credentials.
        DB_CONNECTION: pgsql
        DB_HOST: ${db_hostname}
        DB_PORT: ${db_port}
        DB_DATABASE: ${db_dbName}
        DB_USERNAME: ${db_user}
        DB_PASSWORD: ${db_password}
        # Valkey (Redis-compatible) for cache, sessions, and
        # queues — single service handles all three concerns.
        # predis client is a pure-PHP Redis client that needs
        # no compiled extension.
        REDIS_CLIENT: predis
        REDIS_HOST: ${redis_hostname}
        REDIS_PORT: ${redis_port}
        SESSION_DRIVER: redis
        CACHE_STORE: redis
        QUEUE_CONNECTION: redis
        # S3-compatible object storage backed by MinIO.
        # forcePathStyle is mandatory — MinIO does not support
        # virtual-hosted bucket addressing.
        FILESYSTEM_DISK: s3
        AWS_ACCESS_KEY_ID: ${storage_accessKeyId}
        AWS_SECRET_ACCESS_KEY: ${storage_secretAccessKey}
        AWS_DEFAULT_REGION: us-east-1
        AWS_BUCKET: ${storage_bucketName}
        AWS_ENDPOINT: ${storage_apiUrl}
        AWS_USE_PATH_STYLE_ENDPOINT: "true"
        # Meilisearch for full-text search via Laravel Scout.
        # The host uses internal HTTP — SSL is terminated at
        # the L7 balancer, not between services.
        SCOUT_DRIVER: meilisearch
        MEILISEARCH_HOST: http://${search_hostname}:${search_port}
        MEILISEARCH_KEY: ${search_masterKey}
        # Mail set to log driver — no external SMTP configured.
        # Replace with real SMTP credentials for production use.
        MAIL_MAILER: log

  # Dev — full source deployed for live editing via SSHFS.
  # PHP-FPM serves requests immediately; edit files in /var/www
  # and changes take effect on the next request — no restart.
  - setup: dev
    build:
      # Same multi-base as prod — both PHP and Node available
      # during the build so Composer and npm can run.
      base:
        - php@8.4
        - nodejs@22
      buildCommands:
        # Full Composer install with dev packages — testing and
        # debugging tools available over SSH.
        - composer install
        # Pre-populate node_modules so the developer can run
        # npm run dev (Vite HMR) immediately after SSH-ing in
        # without waiting for another install.
        - npm install
      # Deploy the entire working directory — source files,
      # vendor/, node_modules/, and zerops.yaml so zcli push
      # works from the dev container.
      deployFiles:
        - ./
      cache:
        - vendor
        - node_modules

    run:
      base: php-nginx@8.4
      documentRoot: public
      # Install Node on the runtime container so the developer
      # can run Vite dev server (npm run dev) over SSH. This
      # runs once and is cached into the runtime image — not
      # re-executed on every container restart.
      prepareCommands:
        - sudo -E zsc install nodejs@22
      # Migration + seed runs once per deploy — DB is ready
      # when the SSH session starts. No cache warming in dev
      # — we want config changes to take effect immediately.
      initCommands:
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan migrate --force
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan db:seed --force
        - zsc execOnce ${appVersionId} --retryUntilSuccessful -- php artisan scout:import "App\\Models\\Article"
      envVariables:
        APP_NAME: "Laravel Zerops"
        # Dev mode — detailed error pages with stack traces,
        # no config caching, verbose logging for debugging.
        APP_ENV: local
        APP_DEBUG: "true"
        APP_URL: ${zeropsSubdomain}
        # Debug-level syslog logging surfaces all framework
        # events in the Zerops log viewer via local0 facility.
        LOG_CHANNEL: syslog
        LOG_SYSLOG_FACILITY: local0
        LOG_LEVEL: debug
        # Same service wiring as prod — only mode flags differ.
        DB_CONNECTION: pgsql
        DB_HOST: ${db_hostname}
        DB_PORT: ${db_port}
        DB_DATABASE: ${db_dbName}
        DB_USERNAME: ${db_user}
        DB_PASSWORD: ${db_password}
        REDIS_CLIENT: predis
        REDIS_HOST: ${redis_hostname}
        REDIS_PORT: ${redis_port}
        SESSION_DRIVER: redis
        CACHE_STORE: redis
        QUEUE_CONNECTION: redis
        FILESYSTEM_DISK: s3
        AWS_ACCESS_KEY_ID: ${storage_accessKeyId}
        AWS_SECRET_ACCESS_KEY: ${storage_secretAccessKey}
        AWS_DEFAULT_REGION: us-east-1
        AWS_BUCKET: ${storage_bucketName}
        AWS_ENDPOINT: ${storage_apiUrl}
        AWS_USE_PATH_STYLE_ENDPOINT: "true"
        SCOUT_DRIVER: meilisearch
        MEILISEARCH_HOST: http://${search_hostname}:${search_port}
        MEILISEARCH_KEY: ${search_masterKey}
        MAIL_MAILER: log

  # Worker — background job processor consuming from Redis queue.
  # Same codebase as the app, different entry point. No HTTP
  # traffic — no healthCheck, readinessCheck, or documentRoot.
  - setup: worker
    build:
      # Worker only needs PHP — no asset compilation. The queue
      # runner processes jobs, not HTTP requests with CSS/JS.
      base:
        - php@8.4
      buildCommands:
        - composer install --no-dev --optimize-autoloader
      deployFiles:
        - app
        - bootstrap
        - config
        - database
        - public
        - resources/views
        - routes
        - storage
        - vendor
        - artisan
        - composer.json
      cache:
        - vendor

    run:
      # php-nginx base provides the PHP runtime. The queue:work
      # command runs as the foreground process instead of FPM.
      base: php-nginx@8.4
      # artisan queue:work processes jobs from the Redis queue.
      # --sleep=3 polls every 3s when idle, --tries=3 retries
      # failed jobs before marking them as permanently failed.
      start: php artisan queue:work --sleep=3 --tries=3
      # Cache framework config on every container start so the
      # worker resolves env vars and service references correctly.
      initCommands:
        - php artisan config:cache
      envVariables:
        APP_NAME: "Laravel Zerops"
        APP_ENV: production
        APP_DEBUG: "false"
        APP_URL: ${zeropsSubdomain}
        LOG_CHANNEL: syslog
        LOG_SYSLOG_FACILITY: local0
        LOG_LEVEL: warning
        DB_CONNECTION: pgsql
        DB_HOST: ${db_hostname}
        DB_PORT: ${db_port}
        DB_DATABASE: ${db_dbName}
        DB_USERNAME: ${db_user}
        DB_PASSWORD: ${db_password}
        REDIS_CLIENT: predis
        REDIS_HOST: ${redis_hostname}
        REDIS_PORT: ${redis_port}
        # Worker shares the same Redis-backed drivers as the app.
        # Sessions are configured but unused by the CLI process.
        SESSION_DRIVER: redis
        CACHE_STORE: redis
        QUEUE_CONNECTION: redis
        FILESYSTEM_DISK: s3
        AWS_ACCESS_KEY_ID: ${storage_accessKeyId}
        AWS_SECRET_ACCESS_KEY: ${storage_secretAccessKey}
        AWS_DEFAULT_REGION: us-east-1
        AWS_BUCKET: ${storage_bucketName}
        AWS_ENDPOINT: ${storage_apiUrl}
        AWS_USE_PATH_STYLE_ENDPOINT: "true"
        SCOUT_DRIVER: meilisearch
        MEILISEARCH_HOST: http://${search_hostname}:${search_port}
        MEILISEARCH_KEY: ${search_masterKey}
        MAIL_MAILER: log
```

### 2. Trust the reverse proxy

Zerops terminates SSL at its L7 balancer and forwards requests via reverse proxy. Without trusting the proxy, Laravel rejects CSRF tokens and generates `http://` URLs instead of `https://`. In `bootstrap/app.php`:

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: '*');
})
```

### 3. Configure Redis client

Laravel defaults to the `phpredis` C extension. On Zerops, the `predis` pure-PHP client avoids needing a compiled extension. Install via Composer and set `REDIS_CLIENT=predis` in your environment:

```bash
composer require predis/predis
```

### 4. Configure S3 object storage

Install the S3 Flysystem adapter and set `FILESYSTEM_DISK=s3` with the Zerops object storage credentials. Path-style endpoints are mandatory for the MinIO-backed storage:

```bash
composer require league/flysystem-aws-s3-v3
```

### 5. Configure Meilisearch search

Install Laravel Scout with the Meilisearch driver for full-text search. Add the `Searchable` trait to models you want indexed:

```bash
composer require laravel/scout meilisearch/meilisearch-php
```

### 🎯 What's next?

**Deploy other environments** — Ready to scale? Deploy additional environments for different stages of your workflow:

- [AI Agent](https://app.zerops.io/recipes/laravel-showcase.md?environment=ai-agent)
- [Remote (CDE)](https://app.zerops.io/recipes/laravel-showcase.md?environment=remote-cde)
- [Local](https://app.zerops.io/recipes/laravel-showcase.md?environment=local)
- [Stage](https://app.zerops.io/recipes/laravel-showcase.md?environment=stage)
- [Highly-available Production](https://app.zerops.io/recipes/laravel-showcase.md?environment=highly-available-production)

## Knowledge Base

### Platform Reference

- [Routing & Domains](https://docs.zerops.io/features/access)
- [Scaling](https://docs.zerops.io/features/scaling)
- [Environment Variables](https://docs.zerops.io/features/env-variables)
- [CLI (zcli)](https://docs.zerops.io/references/cli)

### Service Type Reference

**PHP+Nginx**

- [Build & Deploy](https://docs.zerops.io/nginx/how-to/build-pipeline)
- [Customize Web Server](https://docs.zerops.io/nginx/how-to/customize-web-server)
- [SEO Setup](https://docs.zerops.io/nginx/how-to/customize-web-server#seo--prerender-support)

**PostgreSQL**

- [Connect](https://docs.zerops.io/postgresql/how-to/connect)
- [Backup & Restore](https://docs.zerops.io/postgresql/how-to/backup)
- [Manage](https://docs.zerops.io/postgresql/how-to/manage)
- [Scale](https://docs.zerops.io/postgresql/how-to/scale)

**Valkey**

- [Configuration & Access](https://docs.zerops.io/valkey/overview#service-configuration)

**Meilisearch**

- [Configuration](https://docs.zerops.io/meilisearch/overview#service-configuration)
- [Access](https://docs.zerops.io/typesense/overview#access-methods)
- [Backup & Restore](https://docs.zerops.io/typesense/overview#backup)

### Application Reference

#### app Knowledge Base

### Gotchas

- **No `.env` file** — Zerops injects environment variables as OS env vars. Creating a `.env` file with empty values shadows the OS vars, causing `env()` to return `null` for every key that appears in `.env` even if the platform has a value set.
- **Cache commands in `initCommands`, not `buildCommands`** — `config:cache`, `route:cache`, and `view:cache` bake absolute paths into their cached files. The build container runs at `/build/source/` while the runtime serves from `/var/www/`. Caching during build produces paths like `/build/source/storage/...` that crash at runtime with "directory not found."
- **`APP_KEY` is project-level** — Laravel's encryption key must be shared across all services that read the same database (app + worker both need the same key for sessions and encrypted columns). Set it once at project level in Zerops; do not add it per-service or in `zerops.yaml envVariables`.
- **PDO PostgreSQL extension** — The `php-nginx` base image includes `pdo_pgsql` out of the box. No `prepareCommands` or `apk add` needed for PostgreSQL connectivity.
- **Predis over phpredis** — The `php-nginx` base image does not include the `phpredis` C extension. Use the `predis/predis` Composer package and set `REDIS_CLIENT=predis` to avoid "class Redis not found" errors.
- **Object storage requires path-style** — Zerops object storage uses MinIO, which requires `AWS_USE_PATH_STYLE_ENDPOINT=true`. Without it, the SDK attempts virtual-hosted bucket URLs that MinIO cannot resolve.
- **Vite manifest missing on dev after fresh deploy** — the `dev` setup intentionally omits `npm run build` from `buildCommands` so the HMR workflow (`npm run dev` via SSH) stays fast. Any view rendering `@vite(...)` therefore 500s with `Vite manifest not found at: /var/www/public/build/manifest.json` on the first request after a `zerops_deploy`. Fix: run `ssh appdev 'cd /var/www && npm run build'` once after the deploy and before `zerops_verify` — SSHFS propagates the manifest into the container without a redeploy. For iterative work, `ssh appdev 'cd /var/www && nohup npm run dev > /tmp/vite.log 2>&1 &'` drops `public/build/hot` and Laravel routes asset URLs to the dev server. **Do NOT add `npm run build` to dev `buildCommands`** — it adds ~20–30 s to every `zcli push` and defeats the HMR-first design.

#### worker Knowledge Base

### Gotchas

- **No `.env` file** — Zerops injects environment variables as OS env vars. Creating a `.env` file with empty values shadows the OS vars, causing `env()` to return `null` for every key that appears in `.env` even if the platform has a value set.
- **Cache commands in `initCommands`, not `buildCommands`** — `config:cache`, `route:cache`, and `view:cache` bake absolute paths into their cached files. The build container runs at `/build/source/` while the runtime serves from `/var/www/`. Caching during build produces paths like `/build/source/storage/...` that crash at runtime with "directory not found."
- **`APP_KEY` is project-level** — Laravel's encryption key must be shared across all services that read the same database (app + worker both need the same key for sessions and encrypted columns). Set it once at project level in Zerops; do not add it per-service or in `zerops.yaml envVariables`.
- **PDO PostgreSQL extension** — The `php-nginx` base image includes `pdo_pgsql` out of the box. No `prepareCommands` or `apk add` needed for PostgreSQL connectivity.
- **Predis over phpredis** — The `php-nginx` base image does not include the `phpredis` C extension. Use the `predis/predis` Composer package and set `REDIS_CLIENT=predis` to avoid "class Redis not found" errors.
- **Object storage requires path-style** — Zerops object storage uses MinIO, which requires `AWS_USE_PATH_STYLE_ENDPOINT=true`. Without it, the SDK attempts virtual-hosted bucket URLs that MinIO cannot resolve.
- **Vite manifest missing on dev after fresh deploy** — the `dev` setup intentionally omits `npm run build` from `buildCommands` so the HMR workflow (`npm run dev` via SSH) stays fast. Any view rendering `@vite(...)` therefore 500s with `Vite manifest not found at: /var/www/public/build/manifest.json` on the first request after a `zerops_deploy`. Fix: run `ssh appdev 'cd /var/www && npm run build'` once after the deploy and before `zerops_verify` — SSHFS propagates the manifest into the container without a redeploy. For iterative work, `ssh appdev 'cd /var/www && nohup npm run dev > /tmp/vite.log 2>&1 &'` drops `public/build/hot` and Laravel routes asset URLs to the dev server. **Do NOT add `npm run build` to dev `buildCommands`** — it adds ~20–30 s to every `zcli push` and defeats the HMR-first design.

---

## Related Recipes

- [Laravel minimal](https://app.zerops.io/recipes/laravel-minimal.md)
- [PHP Hello world](https://app.zerops.io/recipes/php-hello-world.md)
- [Bun Hello World](https://app.zerops.io/recipes/bun-hello-world.md)
- [Go Hello World](https://app.zerops.io/recipes/go-hello-world.md)

