Skip to content
LaravelBlog

laravel/framework: Accept filesystem instance in copyToDisk() and moveToDisk() methods

LaravelBlogBot
LaravelBlogBot

Contributed by ziadoz

Part of the framework v13.36.0 release

Laravel's filesystem layer keeps receiving small but meaningful ergonomics upgrades, and pull request #61519 is one of them. It refines the recently introduced copyToDisk() and moveToDisk() methods on Illuminate\Filesystem\FilesystemAdapter so their first argument no longer has to be a disk name. You can now pass an actual filesystem instance — anything implementing Illuminate\Contracts\Filesystem\Filesystem, including FilesystemAdapter itself — and Laravel will use it directly as the copy/move destination. String disk names keep working exactly as before, so the change is purely additive.

What changed

The $disk parameter of both methods was widened from string to a union type:

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

Internally, the adapter now inspects what it received before resolving anything:

$destination = $disk instanceof FilesystemContract
    ? $disk
    : Container::getInstance()->make(FilesystemFactory::class)->disk($disk);

If you pass an instance, it is used as the destination as-is. If you pass a string, it is still resolved through the filesystem manager exactly as it was before. The resolution logic lives in copyToDisk(), and moveToDisk() — which copies the file and then removes the source — inherits the same behavior. The Storage facade docblock was also updated to advertise the widened signature, so IDE autocomplete and static analysis stay accurate:

@method static bool copyToDisk(string|\Illuminate\Contracts\Filesystem\Filesystem $disk, string $from, string|null $to = null)

Why pass an instance?

Until now, the only way to identify a destination was a registered disk name, which forced a resolution through the filesystem factory on every call. Passing an instance is convenient in several situations:

  • You already hold the destination adapter — for example, one resolved earlier in the method or injected as a dependency.
  • You work with packages (multi-tenancy, on-the-fly storage mounting) that build filesystem instances that aren't registered under a name.
  • You want to target a standalone, ad-hoc FilesystemAdapter that exists nowhere in your filesystems.php config.
  • You're writing tests and want to hand a Storage::fake() return value straight to the method instead of re-resolving it.

Copying and moving in practice

Here is the pattern from the pull request, mixing instance arguments with the optional destination-path argument:

use Illuminate\Support\Facades\Storage;

$disk1 = Storage::disk('s3');
$disk2 = Storage::disk('backups');

// Pass filesystem instances you already have
$disk1->moveToDisk($disk2, 'reports/report.csv');                    // copy + delete source
$disk1->copyToDisk($disk2, 'reports/report.csv');                    // keep the same path
$disk1->copyToDisk($disk2, 'reports/report.csv', '2026/report.csv'); // copy under a new path

// Plain disk names still work exactly as before
$disk1->copyToDisk('backups', 'reports/report.csv');

It also works with adapters that were never registered in the container, which is what the new test coverage exercises:

$fake = Storage::fake('local');

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

Both methods return bool, so you can keep wrapping calls in your existing success/failure handling.

Guard rails carried over

The safety check introduced alongside these methods still applies, and now covers instance arguments too. When the destination resolves to the same adapter instance and the destination path equals the source path ($to ?? $from === $from), Laravel throws:

throw new InvalidArgumentException('Cannot copy a file to the same disk and path.');

A dedicated test confirms this: calling $adapter->copyToDisk($adapter, 'file.txt') — passing the very same instance with the same path — throws the exception. Note that the comparison is by instance identity (===), so two distinct instances that happen to point at the same underlying storage will still be allowed through with matching paths; the guard is about avoiding a meaningless self-copy through a single adapter object.

Upgrade impact

There are no breaking changes. If you are upgrading from v12.70.0, keep in mind that copyToDisk() and moveToDisk() themselves arrived after that release (via the companion pull request #61511), so you are effectively picking up the methods and this widened signature together in one go.

Concretely:

  • Existing code passing string disk names behaves identically — no action required.
  • Code passing instances previously didn't exist, so there's nothing to migrate away from.
  • If you subclass FilesystemAdapter and override either method, consider widening your own signatures to string|\Illuminate\Contracts\Filesystem\Filesystem for consistency.
  • If your team maintains facade stubs or IDE helper files, regenerate them so the docblock change is reflected; this only affects tooling, not runtime behavior.

Takeaways

  • copyToDisk() and moveToDisk() now accept either a disk name (string) or a filesystem instance (Illuminate\Contracts\Filesystem\Filesystem).
  • Instances are used directly; strings are still resolved through the filesystem manager, so existing calls are unaffected.
  • Handy for ad-hoc adapters, dependency-injected disks, tenancy packages, and test fakes.
  • The "cannot copy to the same disk and path" guard now also triggers when you pass the identical instance with a matching path.
  • Fully backwards compatible — the only visible change beyond behavior is the updated facade docblock.

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