11 min read

Stop Sharing .env Files in Slack: Laravel's Built-In env:encrypt Workflow

Laravel can encrypt .env files so they live in git, readable only with the key. The workflow, the new diff-friendly mode, and the gotchas.

Stop Sharing .env Files in Slack: Laravel's Built-In env:encrypt Workflow

A recent r/laravel thread asked how teams share .env secrets with teammates and across environments. One of the most upvoted answers was just the word Slack. It was a joke, but the replies made clear it wasn't entirely one. And the comment that explained Laravel's built-in answer got a reply from someone who'd never heard of it.

It exists, and it's been in the framework since Laravel 9. php artisan env:encrypt turns a .env file into something you can commit, and env:decrypt turns it back on the server. Two recent releases made it much better for teams. Laravel 12.45 added a --readable mode that keeps variable names visible, and Laravel 13.32 (15 September 2026) made updates to that file leave unchanged values alone. Together they turn the encrypted file into something you can actually review in a pull request.

First, most people shouldn't have these values at all

Before encrypting anything, shrink the problem. The best reply in that thread made this point, and it's right. A new developer doesn't need staging or production credentials. They need .env.example and values that work on their own machine.

cp .env.example .env
php artisan key:generate

With Sail, Herd or Docker, the local database, mail catcher and queue have known defaults. Stripe sandboxes give each developer their own test keys. Most third-party APIs let you create a personal sandbox key. If a value only exists in production, it should only exist where production runs.

That leaves a much smaller set of secrets that really need sharing. The staging values a few people deploy with, and the production values the deploy pipeline needs. That's what env:encrypt is for.

How env:encrypt works

The command reads .env (or .env.{name} with --env), encrypts it with AES-256-CBC by default, and writes the same filename with .encrypted on the end. It prints the key once. Laravel doesn't store it anywhere, so it goes straight into your password manager.

The official docs cover the basics. Here's the version worth using in 2026, with --readable:

php artisan env:encrypt --env=production --readable
   INFO  Environment successfully encrypted.

  Key .................... base64:BTdpjAo5VKt267gpICiMwq/F+/jvc4iTM0OjufNjmrc=
  Cipher ......................................................... AES-256-CBC
  Encrypted file ................................... .env.production.encrypted

Without --readable, the output is one opaque blob. With it, every variable keeps its name and only the value is encrypted:

APP_NAME=eyJpdiI6Ikl1T2hnelpQMkFkWSttUGluNUZ1MFE9PSIsInZhbHVlIjoiQ3NldFRnS0Y3YXl...
APP_ENV=eyJpdiI6IkZkN3VXaGYrMnNDZVBkallEVzRweUE9PSIsInZhbHVlIjoiek1mOFhXTGM4c2gx...
APP_KEY=eyJpdiI6Ijkra0dpVlRuaWMvQ0dHdjFPbFVMaUE9PSIsInZhbHVlIjoicUxaQVpnOVVTamww...
DB_PASSWORD=eyJpdiI6IjZVeVRmbHBzMmI4V1BkVVhFMUtRUlE9PSIsInZhbHVlIjoiL2RRTlNLOEYx...
STRIPE_SECRET=eyJpdiI6IkYzN3ZJeitrUVBQNlZOMlVadmVPcVE9PSIsInZhbHVlIjoiTzJWa1I0bj...

Anyone can see which keys exist. Nobody without the key can see a single value. Comments and blank lines from the source file are dropped, so keep your documentation in .env.example.

Why the 13.32 change matters for pull requests

Before Laravel 13.32, re-encrypting rewrote every value, because each encryption uses a fresh random IV. Change one password and the diff showed every line changed. You couldn't tell from the PR what actually happened.

Now, when the encrypted file already exists, --readable asks for the existing key, decrypts what's there, and keeps the ciphertext for any value that didn't change. Here I rotated the database password and added a Postmark token, then re-encrypted with the same key:

php artisan env:encrypt --env=production --readable --key="base64:BTdp..."
git diff --stat
 .env.production.encrypted | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)
 STRIPE_SECRET=eyJpdiI6IkYzN3ZJeitrUVBQNlZOMlVadmVPcVE9PSIsInZhbHVlIjo...
-DB_PASSWORD=eyJpdiI6IjZVeVRmbHBzMmI4V1BkVVhFMUtRUlE9PSIsInZhbHVlIjoiL...
+DB_PASSWORD=eyJpdiI6ImpmRHBWR2djcnlqQlhSK3VGekUyZVE9PSIsInZhbHVlIjoiU...
+POSTMARK_TOKEN=eyJpdiI6ImdVcjYxNjBybGM0NHlHVno5bWpkNlE9PSIsInZhbHVlIj...

A reviewer sees exactly what changed. One password rotated, one new key added.

Try it without the key and it refuses rather than silently re-encrypting everything:

   ERROR  The existing encryption key is required to update the encrypted environment file.

That also gives you access control for free. A developer adding a new variable for their feature puts it in .env.example. Only someone who holds the production key can put the real value into .env.production.encrypted. To re-encrypt everything on purpose (a new key, say), pass --force.

The full workflow

1. One key per environment

Generate separate keys for staging and production. Someone who needs to deploy staging shouldn't be able to read production, and with one shared key they could. Each key goes into your password manager, and into the deploy platform for that environment.

2. Editing a file

The encrypted file is the source of truth. The plaintext only exists while you're editing:

php artisan env:decrypt --env=staging --key="base64:..."
# edit .env.staging
php artisan env:encrypt --env=staging --readable --key="base64:..." --prune

--prune deletes the plaintext .env.staging after encrypting. Use it every time, because of the first gotcha below.

3. Decrypting at deploy

On the server (or in the pipeline step that runs there), the key comes from an environment variable called LARAVEL_ENV_ENCRYPTION_KEY. env:decrypt reads it automatically, so the key never appears in a command or a log line.

php artisan env:decrypt --env=production --filename=.env --force
php artisan config:cache

In GitHub Actions, the key is a repository secret:

- name: Decrypt production environment
  run: php artisan env:decrypt --env=production --filename=.env --force
  env:
    LARAVEL_ENV_ENCRYPTION_KEY: ${{ secrets.LARAVEL_ENV_ENCRYPTION_KEY }}

On Forge, Laravel Cloud or a plain VPS with a deploy script, set the variable in whatever environment that script runs in. If you're comparing those platforms anyway, I wrote up Cloud vs Forge vs a VPS earlier this year. And if your deploy runs through a task runner like Envoy or Scotty (I compared both here), the decrypt is just one more line in the task.

Here's the whole flow in one picture:

The env:encrypt deploy flow A developer edits .env.production locally, encrypts it with env:encrypt --readable --prune and commits only the encrypted file, so git never holds plaintext. The deploy pulls the repo, decrypts to .env using LARAVEL_ENV_ENCRYPTION_KEY from the deploy platform's secrets, then runs config:cache. YOUR MACHINE · PLAINTEXT WHILE EDITING GIT · CIPHERTEXT ONLY SERVER · DEPLOY STEP · PLAINTEXT AGAIN DEPLOY READ FROM ENV Edit the file .env.production Encrypt, delete plaintext env:encrypt --readable --prune Commit the encrypted file .env.production.encrypted Deploy pulls the repo server or CI checkout Decrypt to .env env:decrypt --filename=.env --force Cache the config config:cache Deploy platform secret LARAVEL_ENV_ENCRYPTION_KEY LEGEND THE ONLY THING THAT LEAVES YOUR MACHINE SECRET STORE INJECTED AT DEPLOY, NEVER COMMITTED

Three gotchas I hit while testing this

The default .gitignore doesn't cover .env.staging

A fresh Laravel 13 app ignores .env, .env.backup and .env.production. That's it. Create .env.staging to encrypt it and git status shows this:

?? .env.staging

Untracked, not ignored. One git add -A and your staging secrets are in the repo history in plain text, right next to the encrypted copy. Either use --prune every time, or add a line to .gitignore:

.env.*
!.env.example
!.env.*.encrypted

Secrets in git history are the same class of problem as secrets in your logs. Once they're written somewhere durable, deleting the file doesn't undo it.

Without --filename, the app silently ignores what you decrypted

env:decrypt --env=production writes .env.production, not .env. Laravel only loads .env.production if APP_ENV=production is already set outside the file, as a server or process variable. If it isn't, Laravel looks for .env, doesn't find it, and runs on config defaults.

I tested both. Without APP_ENV set, config('app.name') returned 'Laravel'. With APP_ENV=production exported, it returned Acme. No error either way. That's the dangerous part. So always pass --filename=.env in the deploy step, unless you deliberately set APP_ENV at the server level.

config:cache hides anything you read with env() outside config files

After decrypting and running config:cache, I checked a value straight from the environment:

env('STRIPE_SECRET'); // null

The decrypted file had it. But once config is cached, Laravel stops loading .env, and env() only sees real system variables. The docs say this plainly, yet right after switching to encrypted files it's easy to assume the decrypt step broke. It didn't. Read secrets through a config file:

// config/services.php
'stripe' => [
    'secret' => env('STRIPE_SECRET'),
],

// anywhere else
config('services.stripe.secret');

What to rotate when someone leaves

Rotating the encryption key alone does almost nothing. Every old version of .env.production.encrypted is still in git history, and the person who left still has the old key. They can check out last month's commit and decrypt it.

So the order is:

  1. Rotate the actual secrets they could read. Database passwords, payment provider keys, mail tokens, cloud credentials. This is the step that matters.
  2. Generate a new encryption key and re-encrypt with --readable --force --key=<new key>. Update LARAVEL_ENV_ENCRYPTION_KEY on the platform in the same deploy.
  3. Rotate APP_KEY if they had production access. Laravel's APP_PREVIOUS_KEYS lets you do it without logging every user out. Put the old key there, and Laravel encrypts with the new key while still decrypting with the old ones.

This is also the best argument for keeping the circle small. Every person with the production key is a full rotation when they leave. A developer who only ever had local values costs you nothing.

The same logic applies to tools, not just people. If an AI agent in your terminal can run env:decrypt with the key in scope, it can read production. Keep production credentials out of its reach the same way you would with a contractor.

When to outgrow it

env:encrypt is a good fit for a small team with one or two environments. It costs nothing, needs no new service, and the secrets move with the code. Its weak point is the shared key. Everyone who can decrypt production holds the same key, so you can't revoke one person without rotating it for everyone.

Tool Where the values live Fits when
env:encrypt Encrypted in your repo, key in the deploy platform Small team, a few environments, no new service wanted
SOPS Encrypted in your repo, keys in age, PGP or a cloud KMS You want per-person keys instead of one shared key, still in git
1Password CLI Your 1Password vault, referenced as op://vault/item/field The team already lives in 1Password
Doppler Doppler's hosted service, injected with doppler run -- Many services and environments, one place to manage them
Infisical Hosted, or self-hosted (MIT-licensed core) You want a Doppler-style service on your own infrastructure

SOPS is the closest step up, because the workflow barely changes. It supports dotenv files and is a CNCF sandbox project. The others move secrets out of the repo entirely, which pays off at scale, and before that is just one more service to run.

If you're already sending .env files over Slack, any of these is an improvement. env:encrypt is the one that's already installed.

FAQ

Is it safe to commit .env.encrypted files to git?

Yes, as long as the key isn't in the repo. The values are encrypted with AES-256-CBC and signed, so they can't be read or tampered with without the key. Decrypting with the wrong key fails with "The MAC is invalid." The real risk is the plaintext file next to it, so use --prune or ignore .env.*.

Where should LARAVEL_ENV_ENCRYPTION_KEY be stored?

In two places. A password manager, so a person can recover it, and the deploy platform's secret store, so the pipeline can use it. Never in the repo, and never in a plain .env that gets committed.

Does env:decrypt run automatically when Laravel boots?

No. Laravel never decrypts at runtime. You run env:decrypt as a deploy step, and the app then reads the plain .env it wrote, like any other deploy.

What does the --readable flag do in env:encrypt?

It encrypts each value separately and keeps variable names in plain text. Since Laravel 13.32, updating an existing readable file with the same key also keeps the ciphertext of unchanged values, so a git diff shows only the variables that changed.

Do developers need the production key?

Almost never. Local development runs on .env.example and local defaults. Only the people who change production configuration and the deploy pipeline need it, and keeping that list short is what makes offboarding cheap.

Share: X/Twitter | LinkedIn | | RSS
Hafiz Riaz

About Hafiz

Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.

Get in touch →

Get web development tips via email

Join 50+ developers • No spam • Unsubscribe anytime