imanghafoori/php-abstract-filesystem
A lightweight abstraction layer over PHP filesystems that gives you one consistent API for reading, writing, listing and deleting files across local and remote storage backends, so you can swap drivers without touching application code.
This package is a small abstraction over PHP's native filesystem functions: an interface (the "abstract" filesystem) plus a concrete implementation that delegates to file_put_contents, file_get_contents, unlink, mkdir, etc. The value is that you can type-hint the interface and swap the implementation (native, in-memory fake, remote adapter) without touching calling code. With 5 stars and zero dependents, treat it as a small utility you may need to read end-to-end rather than a framework you can assume behavior from.
Minimal steps:
composer require imanghafoori/php-abstract-filesystem
src/AbstractFilesystem.php or a Contracts/ directory) — this is your public API surface;NativeFilesystem or similarly named) — this tells you exactly which native functions are wrapped and how errors are surfaced.config/app.php for older setups. Check composer.json for a laravel.providers / extra block.$this->app->bind(
\Imanghafoori\AbstractFilesystem\Contracts\Filesystem::class,
\Imanghafoori\AbstractFilesystem\NativeFilesystem::class
);
A good first target is something trivial and low-risk, e.g. a class that writes a generated report to storage/app/reports:
use Imanghafoori\AbstractFilesystem\Contracts\Filesystem;
class ReportWriter
{
public function __construct(private Filesystem $files) {}
public function write(string $path, string $contents): void
{
$this->files->put($path, $contents);
}
}
If the package's method is named write instead of put (or takes arguments in a different order), fix it from the interface — that's why step 2 exists.
Constructor injection of the interface, everywhere. The whole point of the abstraction is substitutability. Never resolve the concrete class directly in application code; always type-hint the contract. This is what lets you swap in a fake in tests or an S3-backed adapter later.
Fake / in-memory implementation for tests. Because the contract is small, the highest-leverage thing you can do is implement it yourself against an array for unit tests:
class InMemoryFilesystem implements Filesystem
{
private array $files = [];
public function put(string $path, string $contents): void
{
$this->files[$path] = $contents;
}
public function get(string $path): string
{
return $this->files[$path] ?? throw new \RuntimeException("Missing: $path");
}
public function exists(string $path): bool
{
return array_key_exists($path, $this->files);
}
public function delete(string $path): void
{
unset($this->files[$path]);
}
}
Bind it in TestCase::setUp() or a dedicated test service provider. This is the biggest day-to-day win: your domain services no longer touch the real disk in tests.
Adapter pattern for remote storage. If you later need S3/GCS, write an adapter implementing the same contract and delegate to Laravel's Storage facade or the AWS SDK. Your calling code doesn't change. Keep the adapter's method signatures identical to the contract — don't add convenience methods to the adapter that callers will start depending on.
Decorators for logging, caching, or scoping. A thin filesystem contract is easy to decorate:
class LoggingFilesystem implements Filesystem
{
public function __construct(
private Filesystem $inner,
private LoggerInterface $log
) {}
public function put(string $path, string $contents): void
{
$this->log->info("fs.put", ['path' => $path, 'bytes' => strlen($contents)]);
$this->inner->put($path, $contents);
}
// ...delegate the rest
}
This is where you add path-base scoping (see Gotchas), retry logic, or metrics — not in the application services.
Wrap it behind a domain repository for anything non-trivial. Don't let Filesystem::put() calls leak into controllers or jobs. A ReportRepository with save(Report $r) and find(string $id) keeps path conventions and serialization in one place, and lets you change the filesystem contract (or drop the package entirely) without a wide refactor.
Use in queued jobs and Artisan commands. Both are natural consumers: inject the interface into the job constructor (serializable as long as you don't serialize the filesystem object itself — resolve it in handle() instead). Same for commands: Laravel resolves constructor dependencies automatically.
Integration order of operations. If you're introducing this into an existing Laravel app already using Storage or File, don't convert everything at once. Pick a narrow feature (exports, cache files, upload staging), convert that to the interface, test with the fake, then expand. Converting the whole app gives you nothing but churn.
"Thin layer" means thin guarantees. Expect the wrapper to pass arguments straight through to native functions. That means no path sandboxing, no normalization, no protection against ../ traversal, and no atomic writes. Do not accept user-controlled paths without sanitizing them yourself.
Error handling is likely inconsistent. Native file_get_contents returns false and emits a warning; mkdir returns false; some wrapper authors convert these to exceptions, some don't. Read the implementation and standardize: either catch/false-check at the boundary, or wrap the package in a decorator that converts failures into exceptions you control. Don't rely on @ suppression.
Concurrency and partial writes. There's almost certainly no flock or temp-file-then-rename. If two processes write the same path, or a reader reads mid-write, you get corruption. For anything that matters, write to $path . '.tmp' then rename() in your own service — or use Storage which handles some of this better.
Permissions and umask. Wrapped mkdir/file_put_contents inherit the process umask. In containers or CI, the web user and CLI user often differ; a file written by an Artisan command may be unreadable by PHP-FPM. Set explicit permissions on created directories if the package's makeDirectory doesn't take a mode argument.
Path conventions are on you. Decide once: absolute paths, relative to base path, or relative to storage_path(). The wrapper won't resolve relative paths against anything meaningful — it resolves against the process CWD, which differs between web and queue workers. Always pass absolute paths, or build them in one place.
Config quirks. If the package ships a config file, publish it (php artisan vendor:publish --tag=...) and read it before assuming defaults. With a package this small, config may not exist at all — don't cargo-cult a config file from another filesystem package.
Version pinning. Last release is 2025-02, 5 stars, 0 dependents. That's fine for utility code, but pin the version in composer.json and be prepared to fork or replace it. Since the contract is yours to implement, the exit cost is low — which is the real argument for using it: you're betting on the interface shape, not on the maintainer.
Extension points to prefer. In order of safety: (1) implement the interface in your own class, (2) decorate the concrete implementation, (3) subclass it. Subclassing couples you to its internals; avoid it unless the package documents hooks.
Debugging. Because it's a thin wrapper, strace-level reasoning applies: reproduce failures with the raw native call (file_put_contents($abs, 'x')) to determine whether the bug is in your path handling or the wrapper. Add the absolute path to every log line your decorator emits — half of all "filesystem bugs" are a relative-path surprise.
Don't double-abstract. If you're on Laravel and already use Illuminate\Filesystem\Filesystem or the Storage facade, adding this package on top gives you two contracts for one job. Adopt it only if you specifically want a dependency-free contract that works outside Laravel, or if you're replacing Storage to remove the framework coupling. Otherwise, Storage::disk('local') plus a fake (Storage::fake('local')) already covers the testability story.
How can I help you explore Laravel packages today?