Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Think Filesystem Laravel Package

topthink/think-filesystem

Filesystem abstraction for ThinkPHP, built on Flysystem, providing a unified API for local and cloud storage drivers such as Aliyun OSS, Qiniu, and AWS S3. Handles file uploads, reads, writes, deletes, and URL generation through a simple, consistent interface.

View on GitHub
Deep Wiki
Context7

Getting Started

topthink/think-filesystem is the filesystem layer for ThinkPHP 6/7/8 — it is effectively ThinkPHP's equivalent of Laravel's Storage facade, built on top of league/flysystem. If you're used to Storage::disk('public')->putFile(...), the mental model transfers almost 1:1.

Install / verify

composer require topthink/think-filesystem

Most projects already have it because topthink/framework requires it. Check composer.json before adding — pinning a mismatched version against the framework's expectation is a common source of "class not found" errors.

Config file to create first: config/filesystem.php

return [
    'default' => env('filesystem.driver', 'local'),
    'disks'   => [
        'local' => [
            'type' => 'local',
            'root' => app()->getRuntimePath() . 'storage',
        ],
        'public' => [
            'type'       => 'local',
            'root'       => app()->getRootPath() . 'public/storage',
            'url'        => '/storage',
            'visibility' => 'public',
        ],
    ],
];

Where to look first

  1. think\Filesystem — the manager that resolves disks by name.
  2. think\filesystem\Driver — the base wrapper around League\Flysystem\Filesystem.
  3. think\filesystem\driver\Local — the default adapter wired to League\Flysystem\Local\LocalFilesystemAdapter.
  4. The Flysystem docs — every method you don't see on the driver is proxied straight through via __call.

First use case — avatar upload

use think\facade\Filesystem;

public function upload()
{
    $file = $this->request->file('avatar');
    $path = Filesystem::disk('public')->putFile('avatars', $file);

    return json([
        'path' => $path,                                   // store this in DB
        'url'  => Filesystem::disk('public')->url($path), // render this
    ]);
}

That's the whole loop: putFile → store relative path → url() at render time.

Implementation Patterns

Path vs URL separation. Always persist the relative path returned by putFile (avatars/20250101/abc123.jpg), never the full URL. This lets you switch from /storage to a CDN domain by changing one config key, and lets you move files between disks without a DB migration.

Deterministic vs generated names.

  • putFile($dir, $file) auto-names using the UploadedFile's hash — good for anonymous user content.
  • putFileAs($dir, $file, $name) gives you control — use for things like avatars/{$userId}.jpg or content-addressed storage (sha1 of the file) so replacements are idempotent.
Filesystem::disk('public')->putFileAs(
    'avatars',
    $file,
    $user->id . '.' . $file->extension()
);

Multi-disk layout that scales. A pattern that holds up:

'disks' => [
    'local'   => ['type' => 'local', 'root' => runtime_path() . 'storage'],
    'public'  => ['type' => 'local', 'root' => public_path() . 'storage', 'url' => '/storage'],
    'exports' => ['type' => 'local', 'root' => runtime_path() . 'exports'],
    // 's3'   => ['type' => \League\Flysystem\AwsS3V3\AwsS3V3Adapter::class, ...],
],

Route private content through a controller (exports disk + readStream), and public content through a static URL. Never dump user uploads into runtime/ and expect them to be servable.

Using Flysystem directly when needed. Everything on League\Flysystem\Filesystem is available:

$disk = Filesystem::disk('public');

$disk->writeStream($path, fopen($source, 'r'));
$disk->readStream($path);         // returns a resource
$disk->fileSize($path);
$disk->lastModified($path);
$disk->listContents('avatars', true);
$disk->move($from, $to);

For S3-style backends on Flysystem 3, temporaryUrl($path, $ttl) also proxies through, so presigned downloads work without writing a custom driver.

Custom driver. Any class string under type is treated as a driver class:

// app\filesystem\MemoryDriver.php
class MemoryDriver extends \think\filesystem\Driver
{
    protected function createAdapter()
    {
        return new \League\Flysystem\InMemory\InMemoryFilesystemAdapter();
    }
}
'testing' => ['type' => \app\filesystem\MemoryDriver::class],

This is the cleanest way to unit-test file-heavy services without touching disk.

Validation belongs in the validator, not the filesystem. The package happily writes whatever you throw at it. Use ThinkPHP rules:

$validate = $this->validate([
    'avatar' => 'require|fileSize:5242880|fileExt:jpg,png,webp',
]);

By the time putFile runs, the file should already be known-good.

Upload + cleanup on replace. When overwriting an avatar:

$old = $user->avatar;
$new = Filesystem::disk('public')->putFileAs('avatars', $file, $name);
if ($old && $old !== $new) {
    Filesystem::disk('public')->delete($old);
}
$user->save(['avatar' => $new]);

Order matters: delete after the new write succeeds, or a failed upload leaves the user with no avatar at all.

Gotchas and Tips

The default disk is local, and it is not web-servable. runtime/storage is outside public/. If url() returns a path that 404s, this is almost certainly why. Create a public disk and use it explicitly, or configure nginx/apache to alias runtime/storage — but the former is cleaner.

No storage:link command. ThinkPHP doesn't create the symlink Laravel does. You need ln -s ../storage/app/public public/storage (or an nginx alias) on each environment. Forgetting this on deploy is a classic "works locally, 404 in prod."

url() needs 'url' => in config. Without it, the driver has no base to prefix with and you'll get an unexpected path. Also watch for the double-prefix bug — if config says 'url' => '/storage' and you do '/storage/' . $path, you'll get /storage/storage/.... Just call url().

Drivers are memoized per request. Filesystem::disk('public') returns the same instance until you purge it. Mutating config at runtime after first resolution does nothing. If you must, re-resolve by name — but prefer env-driven config over runtime mutation.

Flysystem major version drift is a real trap. The package supports Flysystem 1.x and 2/3.x lines, and the APIs diverge (exists() vs has(), put() semantics, exception hierarchy). If you rely on __call passthrough for anything beyond the driver's own putFile/putFileAs/url, verify the method name against your installed Flysystem version before shipping. Lock league/flysystem explicitly in composer.json so a transitive bump doesn't break your code.

Windows path separators. Flysystem normalizes to /. Building paths with DIRECTORY_SEPARATOR will produce keys that don't round-trip on Linux. Always concatenate with / and rely on Flysystem to normalize.

Visibility is advisory on local disks. 'visibility' => 'public' doesn't chmod files on a local adapter — it's Flysystem metadata that S3 and other object stores honor. If your files come out as 0644 and your web user can't read them, that's a filesystem-permissions problem, not a config problem.

Don't trust user input as a path. Even though Flysystem 3 blocks .. traversal, get($request->param('path')) is still an information-disclosure risk. Whitelist or regex-validate any path that isn't one you generated yourself.

Debugging tip — inspect the resolved adapter. When a disk behaves unexpectedly, dump the resolved config and adapter before writing:

$disk = Filesystem::disk('public');
dump($disk->getAdapter());
dump($disk->getFilesystem()->getConfig());

getAdapter() and getFilesystem() are the two escapes you need; without them you're guessing at whether the root resolved to the path you think.

Streams, not strings, for large files. putFile opens a stream internally (writeStream), which is good. If you hand-roll put($path, file_get_contents($local)), you'll load the whole thing into memory. Stick with putFile/writeStream for anything user-uploaded.

Case-sensitive filesystems bite on deploy. A path that works on a case-insensitive macOS dev box will 404 on Linux if the filename case differs from how you reference it. Always use the exact string putFile returned.

Extension points worth knowing. Two hooks cover most custom needs: override createAdapter() on a Driver subclass for a new backend, and override url() if you need signed/transformed URLs (e.g., an image-resizer proxy) without touching every call site.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource