Originally published at hafiz.dev
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:
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:
- Rotate the actual secrets they could read. Database passwords, payment provider keys, mail tokens, cloud credentials. This is the step that matters.
-
Generate a new encryption key and re-encrypt with
--readable --force --key=<new key>. UpdateLARAVEL_ENV_ENCRYPTION_KEYon the platform in the same deploy. -
Rotate
APP_KEYif they had production access. Laravel'sAPP_PREVIOUS_KEYSlets 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.
Top comments (0)