Skip to content
LaravelBlog

laravel/framework: Add copyToDisk & moveToDisk to FilesystemAdapter

LaravelBlogBot
LaravelBlogBot

Contributed by jackbayliss

Part of the framework v13.36.0 release

Laravel continues to round off the sharp edges of day-to-day storage work, and PR #61511 is a good example. It adds two new methods to Illuminate\Filesystem\FilesystemAdapter — copyToDisk() and moveToDisk() — which let you copy or move a file from one configured storage disk to another in a single call. Both methods are new in the 13.x series, so if you are upgrading from v12.70.0 you will gain them as soon as you move to a release containing this change. This is a pure feature addition, categorized as Feature: nothing existing changes behavior, and there are no breaking changes.

What's new: copyToDisk() and moveToDisk()

The methods share the same signature, with an optional destination path:

public function copyToDisk(string $disk, string $from, string|null $to = null): bool
public function moveToDisk(string $disk, string $from, string|null $to = null): bool

The first argument is the name of the destination disk as configured in config/filesystems.php, the second is the source path, and the third is an optional destination path that defaults to the same path on the target disk:

// Copy, keeping the original on the local disk
Storage::disk('local')->copyToDisk('backup', 'reports/report.csv');

// Move, deleting the original once the copy succeeds
Storage::disk('local')->moveToDisk('backup', 'reports/report.csv');

// Copy to a different path on the destination disk
Storage::disk('local')->copyToDisk('s3', 'reports/report.csv', '2026/report.csv');

moveToDisk() is implemented on top of copyToDisk(): the source file is only deleted after the copy returns true, so a failed transfer never leaves you without either copy.

Why it matters

Before this change, a disk-to-disk transfer was hand-rolled stream plumbing:

$stream = Storage::disk('local')->readStream('reports/report.csv');

Storage::disk('s3')->writeStream('reports/report.csv', $stream);

fclose($stream);

Storage::disk('local')->delete('reports/report.csv');

This works, but it is easy to get wrong. Forgetting fclose() leaks file handles — a real problem in long-running queue workers — and there is no protection if writeStream() throws mid-transfer. Because the logic is short but non-obvious, teams tend to duplicate it or wrap it in a macro, exactly as the PR author describes. Baking the pattern into the framework means one tested, resource-safe implementation everyone can rely on.

How it behaves: edge cases and guarantees

Under the hood, the new methods read a stream from the source adapter, write it to the destination, and close the stream in a finally block — so the handle is released even if the write fails. If the source file cannot be read, the methods return false rather than throwing. Two behaviors are worth knowing:

// Copying a file onto itself throws — there is nothing sensible to do
Storage::disk('s3')->copyToDisk('s3', 'a.txt');
// InvalidArgumentException: Cannot copy a file to the same disk and path.

// Same disk but a different path is fine — usable as a rename
Storage::disk('s3')->copyToDisk('s3', 'a.txt', 'archive/a.txt');

The guard compares the resolved adapter instances, so it only triggers for a genuine same-disk, same-path no-op. The destination disk is resolved through the FilesystemFactory contract from the container, which is what makes the destination configurable per call while still respecting your config/filesystems.php setup. The Storage facade docblock also gained both method signatures, so IDEs and static analyzers such as PHPStan and Psalm accept Storage::copyToDisk(...) / Storage::moveToDisk(...) calls against the default disk without complaining.

A practical example

The pattern shines in queued jobs, where generated artifacts often need to end up on cloud storage before the worker moves on:

class ArchiveReport implements ShouldQueue
{
    public function __construct(public Report $report) {}

    public function handle(): void
    {
        Storage::disk('local')
            ->moveToDisk('s3', "reports/{$this->report->filename}");

        $this->report->update(['archived_at' => now()]);
    }
}

If the upload to S3 throws, the stream is still closed, the local file is untouched, and the job can be retried safely — all properties that were previously your responsibility to implement by hand.

Upgrade impact

Upgrading from v12.70.0 requires no action to benefit from this change:

  • The two methods are purely additive; existing copy(), move(), readStream(), and writeStream() calls behave exactly as before.
  • The FilesystemFactory contract the methods resolve is bound automatically in every standard Laravel application, so no configuration is needed.
  • One thing to review: if your codebase defined its own copyToDisk() or moveToDisk() macro on FilesystemAdapter (a common workaround, as the PR notes), native methods now take precedence when called on an adapter instance. Check any such macros for signature differences — especially argument order — before relying on the framework versions.
  • New tests cover copy, move, and the same-disk-and-path exception, so behavior is pinned down from day one.

Takeaways

  • copyToDisk() and moveToDisk() are new methods on FilesystemAdapter for one-line, disk-to-disk transfers in Laravel 13.x.
  • Both stream the file and guarantee the stream is closed via finally, even on failure — safe for queue workers.
  • moveToDisk() deletes the source only after a successful copy; a failed transfer changes nothing.
  • Copying a file to the same disk and path throws an InvalidArgumentException; a different path on the same disk is allowed.
  • No breaking changes or upgrade steps — but audit any existing same-named macros for signature conflicts.

Sources

More from this release

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