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.
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
think\Filesystem — the manager that resolves disks by name.think\filesystem\Driver — the base wrapper around League\Flysystem\Filesystem.think\filesystem\driver\Local — the default adapter wired to League\Flysystem\Local\LocalFilesystemAdapter.__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.
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.
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.
How can I help you explore Laravel packages today?