Laravel Secrets in Production: The Complete Guide to env:encrypt, Secrets Manager and Rotation

Laravel secrets management done properly: env:encrypt and SOPS, an AWS Secrets Manager loader, IAM scoping, and zero-downtime APP_KEY rotation.

Steven Richardson
Steven Richardson
· 24 min read

The database password on the last app I inherited had been the same since 2019. It was in a .env on the server, in a pinned Slack message, in three people's Notes apps, and in a docker-compose.yml in a repo nobody had opened in two years. Nobody could tell me who still had it, and nobody could change it, because changing it meant finding every copy first.

That is the normal state of Laravel secrets management, and it is not because anyone was careless. It is because the standard advice — put your secrets in .env, don't commit .env — is a rule about one file, not a strategy. This guide is the strategy: where a secret actually lives inside a running Laravel process, which of the three viable patterns fits your team, and how to rotate a credential, including APP_KEY, without an outage.

Every key, ARN and password in this article is fake but well-formed. Do not paste them anywhere.

Trace where a secret actually lives in a running Laravel process#

Before you choose a pattern, follow one value from disk to use, because the single most common way a secrets migration breaks production is a misunderstanding of this path. A .env file is read exactly once, at bootstrap, by vlucas/phpdotenv. Its contents land in $_ENV and $_SERVER. The env() helper reads from there. Your config/*.php files call env() at load time, and everything downstream — the database manager, the mailer, your service classes — reads config(), never env().

Now add config:cache. That command executes every file in config/ once, serialises the merged array to bootstrap/cache/config.php, and from that moment the framework skips loading .env entirely on every subsequent request and Artisan call. The Laravel configuration documentation is explicit: once cached, env() "will only return external, system level environment variables". That precision matters. It does not always return null. If your platform exports a real environment variable of the same name, env() still returns it — which is exactly why this bug survives staging and detonates in production, where the platform exports a different set of variables than your laptop does.

Prove it to yourself rather than trusting a warning box:

// config/services.php
return [
    'acme' => [
        'token' => env('ACME_TOKEN'),
    ],
];
// app/Providers/AppServiceProvider.php - the bug
public function boot(): void
{
    $this->app->bind(AcmeClient::class, fn () => new AcmeClient(env('ACME_TOKEN')));
}
php artisan tinker --execute 'dump(env("ACME_TOKEN"), config("services.acme.token"));'
# "acme_live_9f2c..."
# "acme_live_9f2c..."

php artisan config:cache
php artisan tinker --execute 'dump(env("ACME_TOKEN"), config("services.acme.token"));'
# null
# "acme_live_9f2c..."

The config value survives because it was baked into the cache file. The env() call in the provider does not. Fix it by binding config('services.acme.token') instead, and keep env() inside config/ where it belongs. Everything else in this guide assumes that discipline, because every pattern below ends with the same two steps: make the secret available as a real environment variable, then cache.

Choose between the three secrets patterns#

Three patterns are genuinely viable in 2026, and the honest answer is that all three are defensible. Choose on the axes that will actually bite you: who can decrypt, what a rotation costs, and what happens at 3am when the store is unreachable.

1. Encrypted file in repo 2. Platform-injected env 3. Runtime/deploy-time fetch
Ciphertext lives In git, next to the code In the platform's database In Secrets Manager / SSM / Vault
Who can decrypt Anyone with the key Anyone with platform access Anyone with the IAM role
Access is revoked by Rotating the key, redeploying Removing a platform user Editing an IAM policy
Rotation cost Re-encrypt, commit, deploy Edit, redeploy Update secret; redeploy or TTL expiry
Audit trail Git history (who changed, not who read) Platform activity log CloudTrail per read
Local dev story Excellent — clone and decrypt Poor — copy/paste from a UI Needs a dev-scoped bundle
Store unreachable Cannot happen Cannot happen Deploy fails, or app boots stale
Extra cost None Included Per-secret and per-API-call charges

The decision usually falls out like this. A small team on Forge with a handful of secrets should use pattern 1 or 2 and stop reading vendor blogs telling them they need Vault. A team running several services on Kubernetes should use pattern 2 with real Secret objects. A team that needs a per-read audit trail, per-environment revocation without a deploy, or automatic rotation should use pattern 3 and accept the operational weight that comes with it.

The one combination I would push back on is pattern 1 with the key stored in the same repository. That is encryption theatre. It changes nothing about who can read the secret, and it makes people feel safe, which is worse than feeling exposed.

Encrypt the environment file with env:encrypt#

Start with pattern 1, because it ships with the framework and needs no infrastructure. Laravel's env:encrypt command encrypts your environment file so the ciphertext can sit safely in source control. Run it against the environment file you want to protect and it writes a sibling .encrypted file, printing the decryption key to your terminal once.

php artisan env:encrypt --env=production

#   ENV file successfully encrypted.
#
#   Key ......................................... base64:0nQ5r7dJ8kWx2vTfLpA6yZs3HbMcE1Ru
#   Cipher ...................................................... AES-256-CBC
#   Encrypted file .............................. .env.production.encrypted

The default cipher is AES-256-CBC, which requires a 32-character key. You can supply your own key rather than accepting the generated one, and you can pick any cipher Laravel's encrypter supports:

php artisan env:encrypt --env=production --key=3UVsEgGVK36XN82KKeyLFMhvosbZN1aF
php artisan env:encrypt --env=staging --cipher=AES-128-CBC --key=qUWuNRdfuImXcKxZ

Laravel 13 adds a --readable flag that is worth adopting on day one. Without it the whole file is one opaque blob and every pull request that touches secrets is an unreviewable binary diff. With it, variable names stay in plaintext and only the values are encrypted:

php artisan env:encrypt --env=production --readable
APP_NAME=eyJpdiI6...
APP_ENV=eyJpdiI6...
APP_KEY=eyJpdiI6...
DB_PASSWORD=eyJpdiI6...

Now a reviewer can see that STRIPE_WEBHOOK_SECRET was added and LEGACY_API_KEY was removed without decrypting anything. Comments and blank lines are dropped in this format, and env:decrypt auto-detects which format was used, so there is no flag to remember on the way back.

On the server, decryption happens before anything else in the deploy:

php artisan env:decrypt --env=production --force

The --force flag lets it overwrite an existing .env, which you want on a redeploy. The key comes from the LARAVEL_ENV_ENCRYPTION_KEY environment variable if it is set, or from --key if you pass it explicitly — and passing it explicitly on the command line means it lands in your shell history and your CI logs, so prefer the environment variable.

Which leaves the only question that matters: where does that key live? In your CI provider's secret store, injected as LARAVEL_ENV_ENCRYPTION_KEY — fine. In your password manager, for humans who need to decrypt locally — fine. In .env.example, a deploy script, or the repository — not fine, and no amount of AES-256 rescues it. env:encrypt solves distribution. It does not solve access control, and it does not solve rotation: revoking one person's access means generating a new key, re-encrypting, and redeploying every environment.

Encrypt per-value with SOPS and age#

If pattern 1 fits but a single shared key does not, reach for SOPS with age. SOPS encrypts values rather than whole files and supports multiple recipients, so each team member holds their own key and access is granted or revoked by editing a list rather than rotating one shared secret.

Generate a key per person, then declare the recipients in a .sops.yaml at the repo root:

age-keygen -o ~/.config/sops/age/keys.txt
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# .sops.yaml
creation_rules:
  - path_regex: \.env\.production$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p,
      age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg
  - path_regex: \.env\.staging$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p,
      age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg,
      age17d5jkm0kt9gh8vfxsfzuqnmfp2dsahch7rhqvw6mz4qynchj7phsm2ptv3
sops --encrypt --input-type dotenv --output-type dotenv .env.production > .env.production.enc
sops --decrypt --input-type dotenv --output-type dotenv .env.production.enc > .env

The encrypted file keeps its keys readable, so git diff still tells you which secret changed. Gitignore the plaintext .env.production and commit only .env.production.enc. Off-boarding becomes a one-line change to .sops.yaml plus sops updatekeys, which is a materially better story than "generate a new key and redeploy everything". The cost is another tool in the chain and a keys.txt on every laptop that now needs its own backup story.

Inject secrets from the platform or a Kubernetes Secret#

Pattern 2 hands the problem to whatever runs your app. On Forge you paste into the environment editor; on Vapor and Laravel Cloud the environment is managed for you and injected at boot. If you are still weighing those up, the Vapor and Forge comparison covers how each one handles environment state differently, which matters more than it sounds once you have four environments.

Kubernetes deserves the detail, because the default advice there is the weaker option. A Secret can reach your pod as environment variables or as a mounted file, and the file mount is better:

apiVersion: v1
kind: Secret
metadata:
  name: acme-app-secrets
type: Opaque
stringData:
  .env: |
    APP_ENV=production
    APP_KEY=base64:J63qRTDLub5NuZvP+kb8YIorGS6qFYHKVo6u7179stY=
    DB_PASSWORD=not-a-real-password

---

apiVersion: apps/v1
kind: Deployment
metadata:
  name: acme-app
spec:
  template:
    spec:
      containers:
        - name: php-fpm
          image: ghcr.io/acme/app:2026.09.04
          volumeMounts:
            - name: env
              mountPath: /var/www/html/.env
              subPath: .env
              readOnly: true
      volumes:
        - name: env
          secret:
            secretName: acme-app-secrets
            defaultMode: 0400

Environment variables are readable from /proc/self/environ by anything running in the container and are inherited by every child process you shell out to. A mounted file is not, and defaultMode: 0400 keeps it readable only by the process owner — a Secret mounted 0644 is one of the most common misconfigurations I see in reviews. The other footgun is envFrom, which splats every key in the Secret into the environment of every process in the container, including anything an attacker gets to run. If you are building the image and manifests from scratch, the walkthrough on taking a Laravel app from Dockerfile to running pod covers the surrounding structure this slots into.

Whatever the platform, the ordering rule from step one still applies: the file or variables must exist before config:cache runs in your entrypoint.

Fetch secrets at deploy time from AWS Secrets Manager#

Pattern 3 gives you the per-read audit trail and the ability to revoke without touching the repo. Install the AWS SDK and write a loader that pulls a bundle of secrets and returns them as a flat array.

composer require aws/aws-sdk-php
<?php

namespace App\Secrets;

use Aws\Exception\AwsException;
use Aws\SecretsManager\SecretsManagerClient;
use Illuminate\Contracts\Cache\Repository as Cache;
use RuntimeException;

final class SecretsManagerLoader
{
    /**
     * @param  list<string>  $secretIds
     */
    public function __construct(
        private readonly SecretsManagerClient $client,
        private readonly Cache $cache,
        private readonly array $secretIds,
        private readonly int $ttlSeconds = 300,
    ) {}

    /**
     * @return array<string, string>
     */
    public function load(): array
    {
        return $this->cache->remember(
            'secrets:'.md5(implode('|', $this->secretIds)),
            $this->ttlSeconds,
            fn (): array => $this->fetch(),
        );
    }

    /**
     * @return array<string, string>
     */
    private function fetch(): array
    {
        try {
            // BatchGetSecretValue retrieves up to 20 secrets in one call.
            $result = $this->client->batchGetSecretValue([
                'SecretIdList' => $this->secretIds,
            ]);
        } catch (AwsException $e) {
            throw new RuntimeException(
                'Unable to retrieve secrets from AWS Secrets Manager: '.$e->getAwsErrorCode(),
                previous: $e,
            );
        }

        if ($errors = $result->get('Errors')) {
            throw new RuntimeException(
                'Secrets Manager returned errors for: '.implode(', ', array_column($errors, 'SecretId'))
            );
        }

        $values = [];

        foreach ($result->get('SecretValues') ?? [] as $secret) {
            $decoded = json_decode($secret['SecretString'], associative: true, flags: JSON_THROW_ON_ERROR);

            foreach ($decoded as $key => $value) {
                $values[$key] = (string) $value;
            }
        }

        return $values;
    }
}

BatchGetSecretValue takes up to twenty secrets per call and accepts either a SecretIdList or Filters, but not both. Use the explicit ID list. Filters require the extra secretsmanager:ListSecrets permission and turn "which secrets does production use?" into a question you answer by reading tags at runtime instead of reading a config file.

Now the deploy-time half — an Artisan command that materialises the bundle into .env and then caches config, in that order:

<?php

namespace App\Console\Commands;

use App\Secrets\SecretsManagerLoader;
use Illuminate\Console\Command;
use Throwable;

final class SecretsSync extends Command
{
    protected $signature = 'secrets:sync {--cache : Run config:cache after writing}';

    protected $description = 'Fetch secrets from the store and write them to .env';

    public function handle(SecretsManagerLoader $loader): int
    {
        try {
            $secrets = $loader->load();
        } catch (Throwable $e) {
            $this->components->error('Secret retrieval failed: '.$e->getMessage());

            return self::FAILURE;
        }

        if ($secrets === []) {
            $this->components->error('Secret store returned an empty bundle; refusing to write .env.');

            return self::FAILURE;
        }

        $lines = [];

        foreach ($secrets as $key => $value) {
            $lines[] = sprintf('%s="%s"', $key, addcslashes($value, '"\\'));
        }

        file_put_contents(base_path('.env'), implode(PHP_EOL, $lines).PHP_EOL);
        chmod(base_path('.env'), 0600);

        $this->components->info(sprintf('Wrote %d secrets to .env.', count($secrets)));

        if ($this->option('cache')) {
            $this->call('config:cache');
        }

        return self::SUCCESS;
    }
}

The trade-off between deploy-time and runtime fetching is worth stating plainly, because most articles pick one and pretend the other does not exist. Deploy-time fetch adds zero request latency and no runtime dependency on AWS, but a rotated secret does nothing until you redeploy. Runtime fetch — the same loader called from a custom bootstrapper before LoadConfiguration — picks up rotations within the cache TTL, but every cold boot now depends on Secrets Manager being available. I default to deploy-time and treat rotation as a deploy, because a deploy is a thing my pipeline already knows how to do safely.

Cache the fetched bundle and fail loudly when the store is unreachable#

The failure mode is the part teams skip, and it is the part that turns a rotation into an incident. Two rules cover almost all of it.

First, fail the deploy rather than booting with partial config. Notice that SecretsSync above returns FAILURE on an exception and on an empty bundle, and only writes .env once it has a complete set. A half-written .env produces an application that boots, serves traffic, and fails on the first request that touches the missing service — which is far harder to diagnose than a deploy that stopped.

Second, never silently fall back to a stale value. Caching the bundle is right, and a short TTL is right, but a cache that serves the old value when the store is unreachable will happily keep using a credential you revoked ten minutes ago because it was leaked. If you must degrade, degrade to an error. Cache::remember() as written above does exactly this: on a miss it calls fetch(), and if fetch() throws, nothing is cached and the caller decides.

Set the TTL against your rotation window, not against your API bill. A five-minute TTL means a revoked credential stops working within five minutes of a rotation. An hour means an hour. Do not treat "reduce API calls" as the objective function here; treat "bound the blast radius" as the objective function, and pick the longest TTL you would be comfortable explaining after an incident. Whichever you choose, make the fetch visible — a failed secrets:sync should page someone, and if you have already wired up Pulse, Nightwatch and OpenTelemetry for production observability, the deploy-time secret fetch is worth a span of its own.

Scope the IAM role to one environment#

An IAM role that can read every secret in the account is not much better than a shared .env. Scope each environment's role to that environment's prefix and nothing else. Name secrets with a predictable prefix so the policy can be written once and never revisited.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadProductionSecretsOnly",
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue",
        "secretsmanager:BatchGetSecretValue"
      ],
      "Resource": "arn:aws:secretsmanager:eu-west-2:111122223333:secret:acme/production/*"
    },
    {
      "Sid": "DecryptWithEnvironmentKey",
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:eu-west-2:111122223333:key/8f4c1b2e-0000-4a9d-b111-2c3d4e5f6a7b",
      "Condition": {
        "StringEquals": {
          "kms:ViaService": "secretsmanager.eu-west-2.amazonaws.com"
        }
      }
    }
  ]
}

Staging gets an identical policy pointing at acme/staging/* and its own KMS key. Now a compromised staging host cannot read production, which is the entire point and is not true by default.

Extend the same thinking past IAM. Give each service its own database user rather than sharing one, so that revoking the reporting service's access does not require rotating the credential the web tier uses. Blast radius is a function of how many things share a credential, and it is much cheaper to split credentials before an incident than during one.

Rotate a database password with zero downtime#

Rotation is not "change the password and redeploy". That sequence has a window — between the database accepting the new password and every worker, scheduler and web process picking it up — where connections fail. Use two credentials and overlap them. If you have done an expand and contract database migration, the shape is identical: add the new thing, move traffic, remove the old thing, and never let a moment exist where old code meets new state.

-- 1. Expand: create the new user alongside the old one.
CREATE USER 'acme_app_2026_09'@'%' IDENTIFIED BY 'a-new-generated-password';
GRANT SELECT, INSERT, UPDATE, DELETE ON acme.* TO 'acme_app_2026_09'@'%';
FLUSH PRIVILEGES;
# 2. Update the secret store, then deploy so every process reads the new user.
aws secretsmanager put-secret-value \
  --secret-id acme/production/database \
  --secret-string '{"DB_USERNAME":"acme_app_2026_09","DB_PASSWORD":"a-new-generated-password"}'

php artisan secrets:sync --cache
php artisan queue:restart
-- 3. Contract: only after every connection is confirmed on the new user.
DROP USER 'acme_old_app'@'%';
Step Action Old credential New credential
T+0 Create new DB user Works Works
T+5m Update secret, deploy web tier Works In use
T+10m queue:restart, wait for workers to cycle Works In use
T+30m Confirm zero connections on old user Idle In use
T+35m Drop old user Revoked In use

Step four is the one people skip, and skipping it means you have added a credential rather than rotated one. Confirm the old user really is idle before dropping it — SELECT user, host, count(*) FROM information_schema.processlist GROUP BY user, host; on MySQL, pg_stat_activity on Postgres. Long-running queue workers are the usual stragglers, which is why queue:restart gets its own step; workers finish the current job and then exit, so the timing depends on your longest job, not on how fast you can type.

Rotate APP_KEY with APP_PREVIOUS_KEYS#

APP_KEY is a different animal, and this is where an afternoon's tidying turns into unrecoverable data loss. It encrypts every cookie including the session cookie, every value passed through Crypt or the encrypt() helper, every encrypted model cast, signed URLs, password reset tokens and remember-me cookies. Change it naively and all of that becomes undecryptable — not corrupted, not recoverable, just gone. Take a database backup before you start, and never let php artisan key:generate run as part of a deploy script.

Laravel's graceful key rotation exists exactly for this. Set the new key as APP_KEY and list the old one in APP_PREVIOUS_KEYS. Laravel always encrypts with the current key, but on decryption it tries the current key first and then walks the previous keys until one succeeds:

APP_KEY="base64:J63qRTDLub5NuZvP+kb8YIorGS6qFYHKVo6u7179stY="
APP_PREVIOUS_KEYS="base64:2nLsGFGzyoae2ax3EF2Lyq/hH6QghBGLIq5uL+Gp8/w="

APP_PREVIOUS_KEYS is a comma-delimited list, so you can carry more than one key through a longer transition. That alone keeps sessions alive and keeps existing encrypted columns readable, but it is a bridge, not a destination: while the old key is still listed it is still a live key, and you have not actually rotated anything until it is gone. Close the gap by re-encrypting stored values, in batches, so the job is restartable:

<?php

namespace App\Console\Commands;

use App\Models\Customer;
use Illuminate\Console\Command;

final class ReencryptCustomerSecrets extends Command
{
    protected $signature = 'secrets:reencrypt {--chunk=500}';

    protected $description = 'Re-encrypt encrypted columns under the current APP_KEY';

    public function handle(): int
    {
        $chunk = (int) $this->option('chunk');
        $touched = 0;

        Customer::query()
            ->whereNotNull('api_token')
            ->chunkById($chunk, function ($customers) use (&$touched): void {
                foreach ($customers as $customer) {
                    // Reading decrypts with whichever key works; saving re-encrypts
                    // with the current APP_KEY.
                    $customer->api_token = $customer->api_token;
                    $customer->saveQuietly();
                    $touched++;
                }

                $this->components->info("Re-encrypted {$touched} rows so far.");
            });

        return self::SUCCESS;
    }
}

chunkById() rather than chunk() matters here because you are writing to the rows you are paginating over, and offset pagination will skip rows when the underlying set shifts. saveQuietly() keeps model events and observers out of a maintenance operation that has no business firing webhooks.

The full sequence: back up, generate a new key, deploy with the new key as APP_KEY and the old key in APP_PREVIOUS_KEYS, run secrets:reencrypt for every model with encrypted casts, verify a sample of rows decrypts with the old key removed in a scratch environment, then deploy again with APP_PREVIOUS_KEYS emptied. Sessions and remember-me cookies you can simply let expire — those users log in again, which is an acceptable cost that stored data does not have.

Stop secrets leaking into exception reports and queue payloads#

A perfectly managed secret store does not help if the credential is printed in a stack trace. PHP puts every function argument in a backtrace, so a connection failure deep in a client library can serialise your API key straight into Sentry. Mark those parameters with the #[\SensitiveParameter] attribute, available since PHP 8.2, and PHP substitutes a SensitiveParameterValue object in the trace while the real value flows through the function untouched:

final class AcmeClient
{
    public function __construct(
        #[\SensitiveParameter] private readonly string $apiToken,
        private readonly string $baseUrl = 'https://api.acme.test',
    ) {}
}

There is a fuller treatment of redacting secrets from PHP stack traces with the SensitiveParameter attribute including what it does and does not cover — the short version being that it changes the trace representation only, not var_dump(), not your own log statements.

Then close the other three doors. Laravel's exception handler flashes request input on validation redirects, so extend dontFlash past the defaults to cover anything credential-shaped:

// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->dontFlash([
        'current_password',
        'password',
        'password_confirmation',
        'api_token',
        'secret',
        'card_number',
    ]);
})

Scrub at the reporting boundary too, because a payload you never send cannot leak:

// config/sentry.php
'before_send' => function (\Sentry\Event $event): ?\Sentry\Event {
    $redact = ['DB_PASSWORD', 'APP_KEY', 'AWS_SECRET_ACCESS_KEY', 'STRIPE_SECRET'];

    foreach ($event->getContexts() as $name => $context) {
        $event->setContext($name, collect($context)
            ->map(fn ($value, $key) => in_array($key, $redact, true) ? '[redacted]' : $value)
            ->all());
    }

    return $event;
},

And watch queue payloads. A job constructor argument is serialised into the queue backend in plaintext and sits there until the job runs — or forever, if it fails and lands in failed_jobs, which is a database table your whole team can read.

// Wrong - the token is now sitting in Redis and possibly in failed_jobs.
SyncAcmeContacts::dispatch($user, $acmeToken);

// Right - resolve the credential inside handle().
SyncAcmeContacts::dispatch($user);

The same discipline applies to Log::withContext(). It is genuinely useful for request-scoped logging context, and it will faithfully attach whatever you give it to every subsequent log line, so give it identifiers rather than credentials.

Give developers a local bundle instead of a copy of production#

The most common real vulnerability in Laravel teams is not the deployment pipeline. It is that a new developer's onboarding step is "ask someone to send you their .env", and that message stays in Slack forever, holding production credentials, searchable by everyone who ever joins the workspace.

Fix it with two things. Keep .env.example genuinely complete, so every variable the app reads is listed with a placeholder and a comment where the value comes from; a missing entry there is what sends people asking for a real file. Then ship a dev-scoped bundle, so secrets:sync against a acme/development/* prefix gives a working local app with sandbox credentials only. Stripe test keys, a seeded local database, a mail catcher — nothing in that bundle should be able to touch a customer.

# Onboarding, in full.
git clone git@github.com:acme/app.git && cd app
composer install && npm install
cp .env.example .env
php artisan secrets:sync   # pulls acme/development/* only
php artisan key:generate
php artisan migrate --seed

If a developer genuinely needs to reproduce something against production data, that is a separate, logged, time-boxed grant — not the default state of every laptop. And while you are auditing what has access to your credentials, auditing your PHP dependencies belongs in the same conversation, because a compromised package runs in the same process as everything you just protected.

Wire secrets into CI with OIDC and the right cache ordering#

Long-lived AWS keys in CI secrets are the thing you are trying to get away from, so do not recreate them in GitHub Actions. Use OIDC federation: the runner presents a short-lived identity token, assumes a role scoped to that repository, and never holds a credential you would have to rotate.

name: Deploy

on:
  push:
    branches: [production]

permissions:
  id-token: write   # required for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'

      - run: composer install --no-dev --optimize-autoloader --no-interaction

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/acme-production-deploy
          aws-region: eu-west-2

      - name: Materialise secrets, then cache
        run: |
          php artisan secrets:sync
          php artisan config:cache
          php artisan route:cache
          php artisan view:cache

The ordering in that last step is the whole point, and it is the failure this guide opened with: fetch, write, then cache. Cache before the secrets exist and you bake a config file full of nulls into the artifact, which will deploy successfully and fail at runtime. Add php artisan config:show database to a smoke-test step if you want the pipeline to catch it rather than your users.

GitHub masks registered secrets in logs, but it cannot mask a value it does not know about — anything you fetch at runtime is unmasked, so never echo a fetched secret, and be careful with set -x. The broader pipeline shape, including how this sits alongside migrations and worker restarts, is covered in the zero-downtime deployment walkthrough with GitHub Actions and Forge, and if your CI minutes are creeping up, caching Composer and npm properly pairs well with it.

Test the integration with Pest#

Secrets handling is testable, and the tests are cheap. Cover three behaviours: config resolves from the store, the sync command fails loudly, and no known secret reaches a rendered error page.

use App\Console\Commands\SecretsSync;
use App\Secrets\SecretsManagerLoader;

it('resolves configuration from the secret bundle', function () {
    $this->mock(SecretsManagerLoader::class)
        ->shouldReceive('load')
        ->andReturn(['ACME_TOKEN' => 'acme_test_value']);

    $this->artisan('secrets:sync')->assertSuccessful();

    expect(file_get_contents(base_path('.env')))->toContain('ACME_TOKEN="acme_test_value"');
});

it('fails the command when the secret store is unavailable', function () {
    $this->mock(SecretsManagerLoader::class)
        ->shouldReceive('load')
        ->andThrow(new RuntimeException('ThrottlingException'));

    $this->artisan('secrets:sync')->assertFailed();
});

it('fails the command rather than writing an empty env file', function () {
    $this->mock(SecretsManagerLoader::class)
        ->shouldReceive('load')
        ->andReturn([]);

    $this->artisan('secrets:sync')->assertFailed();
});

it('never renders a secret on an error page', function () {
    config(['app.debug' => true, 'services.acme.token' => 'acme_live_supersecret']);

    Route::get('/__boom', fn () => throw new RuntimeException('kaboom'));

    $response = $this->withoutExceptionHandling(false)->get('/__boom');

    expect($response->getContent())->not->toContain('acme_live_supersecret');
});

That last test is the one worth keeping forever. It runs with app.debug deliberately on, which is the worst case, and it will fail the moment somebody passes a credential as a constructor argument without #[\SensitiveParameter]. Add an assertion per credential class you care about and let CI hold the line.

What you should have now: one pattern chosen deliberately rather than inherited, secrets that reach the process before config:cache runs, an IAM policy that stops staging reading production, a rehearsed rotation for both database credentials and APP_KEY, and tests that fail when a secret escapes. From here, the natural next steps are hardening the deployment itself with blue-green deployment behind an Nginx upstream and making sure the credentials you just protected are not undone by an unaudited dependency.

FAQ#

How do I store Laravel secrets securely in production?

Pick one of three patterns and commit to it: an encrypted environment file in the repository using env:encrypt or SOPS, environment variables injected by your platform such as Forge, Vapor, Laravel Cloud or a Kubernetes Secret, or a fetch from a dedicated store like AWS Secrets Manager at deploy time. The pattern matters less than the two rules that apply to all of them — the secret must be present as a real environment variable before config:cache runs, and access must be revocable without hunting down copies. A plaintext .env sitting on the server with no rotation story is the only genuinely wrong answer.

Is php artisan env:encrypt safe enough for production?

Yes, for what it does, which is safe distribution of an environment file through source control. It uses AES-256-CBC by default with a 32-character key and the ciphertext is fine to commit. What it does not give you is per-person access control, an audit trail of who read a secret, or cheap revocation — removing one person's access means generating a new key, re-encrypting every environment file and redeploying. If those properties matter to you, use SOPS with age for per-recipient keys or move to a secrets store.

How do I load AWS Secrets Manager values into Laravel?

Install aws/aws-sdk-php, then write a loader that calls batchGetSecretValue with an explicit SecretIdList and returns a flat key-value array. Call it from an Artisan command that writes .env and then runs config:cache, in that order, as part of your deploy. Fetching at deploy time keeps AWS out of your request path at the cost of needing a redeploy to pick up a rotation; fetching at runtime through a custom bootstrapper picks up rotations within your cache TTL but makes every cold boot depend on Secrets Manager being reachable.

Why does env() return null after config:cache?

Because config:cache serialises your merged configuration to bootstrap/cache/config.php and the framework then stops loading .env altogether. Strictly, env() does not always return null afterwards — it returns whatever real system-level environment variables exist, which is why the bug often hides in staging and surfaces in production where a different set of variables is exported. The fix is the rule Laravel has documented for years: call env() only inside config/*.php files and read config() everywhere else.

How do I rotate the Laravel APP_KEY without breaking encrypted data?

Back up the database first, then generate a new key, set it as APP_KEY, and put the old key in the comma-delimited APP_PREVIOUS_KEYS variable. Laravel will encrypt new values with the current key and fall back through the previous keys on decryption, so sessions stay alive and existing encrypted columns stay readable. Then run a batched chunkById() command that reads and re-saves every encrypted column so it is re-encrypted under the new key, verify a sample, and only then deploy again with APP_PREVIOUS_KEYS emptied.

Should I commit .env.encrypted to Git?

Committing the encrypted file is the intended workflow and is safe. Committing the decryption key alongside it is not, and it is a surprisingly common mistake — a key in the repository, in a deploy script or in .env.example means the encryption achieves nothing. Keep the key in your CI provider's secret store as LARAVEL_ENV_ENCRYPTION_KEY and in a password manager for humans, and use the --readable option so pull requests show which variables changed without exposing their values.

How do I stop secrets leaking into Laravel exception reports?

Work in layers. Mark credential parameters with PHP's #[\SensitiveParameter] attribute so they are redacted from stack traces, extend the exception handler's dontFlash list past the password defaults, and add a before_send scrubber in Sentry or your equivalent to strip known credential keys from context. Then check the places people forget: never pass a credential into a queued job's constructor, since it is serialised into the queue and into failed_jobs, and give Log::withContext() identifiers rather than secrets.

Do I need HashiCorp Vault for a Laravel application?

Usually not. Vault earns its operational cost when you need dynamic short-lived credentials, cross-cloud secret brokering, or a policy engine that a cloud provider's IAM cannot express. For a typical Laravel application with one cloud account, AWS Secrets Manager or SSM Parameter Store gives you the same audit trail and per-environment scoping with far less to run and monitor. Choose Vault because a requirement pushed you there, not because it is the most serious-sounding option on the list.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.