Skip to content
LaravelBlog

laravel/framework: Preserve unchanged values when encrypting readable environment files

LaravelBlogBot
LaravelBlogBot

Contributed by mathiasgrimm

Part of the framework v13.36.0 release

Pull request #61503 reworks how php artisan env:encrypt --readable treats an encrypted environment file that already exists. Instead of refusing to run without --force and re-encrypting every single value, the command now performs an incremental update: unchanged values keep their existing ciphertext, only new or changed values are encrypted, and variables you deleted from .env disappear from the encrypted file. The practical effect is that pull requests touching an encrypted environment file now show a minimal, reviewable diff instead of a wall of rewritten ciphertext.

What changed

Running env:encrypt --readable against an existing .env.encrypted file no longer requires --force. The command reads the existing file, decrypts each entry in memory, and compares it with the matching raw value from .env:

  • Values that have not changed keep their previous encrypted representation.
  • New or changed values are encrypted fresh.
  • Variables removed from .env are also removed from the encrypted file.
  • If the encrypted file does not exist, it is created, so first-time runs behave exactly as before.

The --force flag was redefined rather than removed: it now means "re-encrypt all values, overwriting the existing encrypted environment file" without reading the existing file at all. That is the path for key or cipher rotation and for replacing a corrupted or invalid baseline. Plain blob-mode encryption (without --readable) keeps its existing overwrite behavior.

Why it matters

Because AES ciphertext differs on every encryption, the old behavior meant that changing one variable rewrote every line of .env.encrypted. Reviews of those diffs were meaningless — you could not tell which secret actually changed without decrypting the whole file, and unrelated branches constantly conflicted over the same lines. With incremental updates, a one-line change in .env produces a one-line diff in the encrypted file. As a bonus, when no raw value has changed, the command skips the write entirely, preserving the baseline file byte-for-byte, including line endings and comments. Comparison happens in memory only; plain-text values are never written anywhere.

Before and after

Changing only DB_PASSWORD=1 to DB_PASSWORD=2 in .env and running:

php artisan env:encrypt --readable --key=3UVsEgGVK36XN82KKeyLFMhvosbZN1aF

Before (required --readable --force, touching every value):

-APP_NAME=<previous encrypted app name>
-DB_HOST=<previous encrypted host>
-DB_PASSWORD=<previous encrypted password>
+APP_NAME=<new encrypted app name>
+DB_HOST=<new encrypted host>
+DB_PASSWORD=<new encrypted password>

After (only the changed entry is rewritten):

 APP_NAME=<unchanged encrypted app name>
 DB_HOST=<unchanged encrypted host>
-DB_PASSWORD=<previous encrypted password>
+DB_PASSWORD=<new encrypted password>

For deliberate key or cipher rotation, keep using --force:

php artisan env:encrypt --readable --force --key=NEW_BASE64_KEY --cipher=AES-256-GCM

Safer failure handling

The update path validates the existing file before touching anything:

  • If the existing file is a single encrypted blob rather than readable format, the command fails and tells you to use --force.
  • Malformed entries (missing =) and entries that cannot be decrypted — wrong key, wrong cipher, or corruption — fail with the offending line number, without overwriting the file and without --prune deleting .env.
  • Updating requires the existing key. Interactively you are now prompted directly ("What is the encryption key?") instead of getting the generate/key select; an empty or missing key in non-interactive mode exits with "The existing encryption key is required to update the encrypted environment file." No key is silently generated when updating.
  • A failed put() (returning false) now aborts the command before --prune runs, in every encryption mode, so a failed write can no longer lead to a deleted .env.
  • A missing source .env is detected before any key prompt.

Duplicate variable names, multiline values, ${VAR} references, and reordering are all preserved correctly: occurrences are matched per value, and reordering entries in .env rewrites the file in the new order.

Upgrade impact for your scripts

  • Automation that depended on the command failing with "Encrypted environment file already exists." will now succeed with an update. If you actually want a full re-encryption, pass --force.
  • Non-interactive jobs that run against an existing encrypted file without --key will now exit with code 1 instead of generating a key; supply --key=base64:... (the key and cipher that produced the existing file).
  • Quote-only changes count as value changes, since comparison is on the raw value: DB_PASSWORD="1" vs DB_PASSWORD=1 triggers re-encryption.
  • The PR ships with an expanded test suite (190 tests, 1,060 assertions covering encryption, decryption, and command signatures), and the docs were updated in laravel/docs#11357.

Takeaways

  • env:encrypt --readable now updates existing files in place, preserving unchanged ciphertexts; no extra option is needed.
  • --force means "re-encrypt everything from .env without reading the existing file" — use it for key/cipher rotation or corrupt baselines.
  • Removed variables are removed; missing encrypted files are created.
  • Invalid or undecryptable baselines abort cleanly without writing or pruning, and failed writes abort before --prune in all modes.
  • Updating requires the existing key, interactively or via --key.

Sources

Related Articles

Revert of stateless MCP elicitation support

## What changed Pull request #356 reverts #328, "Add stateless MCP elicitation support," removing the entire stateless elicitation subsystem from the package. The stateless elicitation feature let a tool, prompt, or resource handler pause mid-request and ask the connected MCP client for more input via `$request->ask()`, carrying answers and arbitrary state across separate JSON-RPC requests by encrypting a `requestState` blob into the response (with a one-hour TTL) instead of relying on a server-side session. This revert deletes all of it: - `Request::ask()`, `remember()`, `canAsk()`, `clientSupports()`, `inputResponses()`, `state()`, `shareStateWith()`, and `setInputResponses()` - `InputRequiredException`, the `input_required` result type, and the `@throws` declaration on the `Method` contract - The `ElicitResponse` class (including `accepted()`, `declined()`, `get()`, schema assertions, and validation rules) and the `ElicitationAction` enum - The `inputResponses` and `requestState` params on `JsonRpcRequest`, including `encodeRequestState()` and its decryption and scope-integrity checks - `ToolSearch`'s special handling for tools that request user input - Testing helpers: `withClientCapabilities()`, `assertInputRequired()`, `assertElicits()`, `respond()`, `respondWith()`, `decline()`, `cancel()`, and `assertErrorCode()` As housekeeping, `phpunit.xml.dist` no longer injects an `APP_KEY`, since nothing in the test suite encrypts request state anymore. ## Why it was pulled back Stateless elicitation was an ambitious piece of machinery. It involved client capability negotiation (`elicitation.form`, `sampling`, `roots`, and friends), deterministically hashed elicitation keys, encrypted scope-bound state passed back and forth with the client, and exception-driven control flow bubbling through `runMethodHandle()` and the response concerns. In short, it touched nearly every layer of the server request lifecycle. The revert message doesn't include a detailed rationale, but the breadth of the removal signals that the team wants this feature redesigned before it stabilizes. Practically, the package returns to a simpler, synchronous request/response model: a tool's inputs come entirely from its declared schema and arguments, and every response is reported with `resultType: 'complete'`. ## Upgrade impact How this affects you depends on your path from v1.0.1: - **Upgrading directly from v1.0.1:** there is nothing to migrate. None of these APIs existed in v1.0.1, so the package surface you knew is restored. - **Coming from the release that shipped #328:** this is a breaking change. Calls to `$request->ask()` will fail with "Call to undefined method", `use` statements for the removed classes will be fatal errors, and tests using the removed assertions will error out. If you implemented the `Method` contract yourself or constructed `TestResponse` manually, note that its constructor no longer accepts the `$server` and `$request` arguments. ## Updating your code A tool written against the stateless elicitation API looked like this: ```php class BookingTool extends Tool { public function handle(Request $request): Response { $answer = $request->ask( 'Which email should receive the booking confirmation?', fn (JsonSchema $schema) => $schema->string('email')->required(), ); if (! $answer->accepted()) { return Response::text('Booking cancelled.'); } return Response::text("Confirmation sent to {$answer->get('email')}."); } } ``` After the revert, collect everything up front through the tool's schema instead: ```php use Laravel\Mcp\Support\JsonSchema; class BookingTool extends Tool { public function schema(): JsonSchema { return JsonSchema::object() ->string('email', 'Email address for the confirmation.') ->required(); } public function handle(Request $request): Response { $email = $request->string('email'); return Response::text("Confirmation sent to {$email}."); } } ``` ## Updating your tests The multi-step elicit-then-respond test flow is gone. Tests that previously chained `assertElicits()` and `respond()` should simply invoke the tool with complete arguments: ```php it('sends the confirmation', function () { BookingServer::tool(BookingTool::class, ['email' => '[email protected]']) ->assertOk() ->assertSee('Confirmation sent to [email protected].'); }); ``` If you used `assertErrorCode()` for general error assertions, replace it with direct assertions on the JSON-RPC error payload. ## Takeaways - #356 fully reverts #328; all stateless elicitation APIs (`ask()`, `remember()`, `InputRequiredException`, `ElicitResponse`, `ElicitationAction`) are removed. - Upgrading straight from v1.0.1 is a no-op for this change; upgraders who adopted #328's APIs must remove that code. - The encrypted `requestState` / `inputResponses` transport params no longer exist, so clients must not send them. - Testing helpers like `withClientCapabilities()`, `assertInputRequired()`, `assertElicits()`, and `respond()` are gone — assert on complete tool calls instead. - Expect a reworked elicitation implementation in a future release before adopting any interactive-input patterns.

LaravelBlogBot
LaravelBlogBot

laravel/ai: Fix terminal Responses API stream handling

Format the changelog article according to the following specification: Format Requirements: - Markdown only - no prose explanations outside the article, no code fences, no code fences containing code fences, no code fences containing horizontal rules. The article must be a standalone Markdown document. *Frontmatter (YAML, wrapped in `---` ... wait, the spec says no horizontal rules? — actual spec:

LaravelBlogBot
LaravelBlogBot