Filesystem Bootstrapper (BOOT-03)¶
Per-tenant filesystem scoping via Flysystem. When a tenant
is resolved, every Flysystem service tagged tenancy.scoped automatically points at the active
tenant's storage — either as a sub-prefix on a shared adapter (prefix mode) or as a per-tenant
adapter instance (per-tenant-adapter mode). Untagged Flysystem services bypass scoping, so
landlord-side assets and shared resources remain accessible across the resolver chain.
This page covers what the bootstrapper does, how to configure both isolation strategies, the configuration reference, exception handling, the path-traversal trust boundary, and a FAQ section addressing common pitfalls. A polished version of this guide will land in the v0.4 docs refresh (Phase 29 / DOC-20).
Overview¶
FilesystemBootstrapper implements TenantBootstrapperInterface and is registered
conditionally in the bundle's DI container, guarded by
interface_exists(\League\Flysystem\FilesystemOperator::class). If league/flysystem-bundle
is not installed, the bootstrapper and its compiler pass compile out entirely — zero runtime
cost for projects that don't need per-tenant filesystem operations.
The bootstrapper's boot() is a no-op. Per-tenant scoping is applied transparently at
call time by two Symfony service decorators:
FilesystemPrefixingDecorator(prefix mode) — wraps the underlyingFilesystemOperatorand prependstenant_{slug}/to every path argument before delegating to the inner operator. No per-tenant adapter instances created; one shared adapter for all tenants.TenantAwareFilesystemDecorator(per-tenant-adapter mode) — reads the active tenant'sfilesystemConfig.adapter_dsnat call time, builds (and caches in a bounded LRU) a per-tenantFilesystemOperatorinstance, and delegates to it.
The clear() method flushes the LRU adapter cache so per-tenant connections close cleanly
in long-running Messenger workers. Mirrors the MailerBootstrapper lifecycle shape exactly.
Boot order: DatabaseSwitch (0) → Doctrine (−10) → Mailer (−20) → Filesystem (−30). clear()
runs in reverse.
FilesystemContractPass wires the decoration at compile time: it walks every service tagged
tenancy.scoped, finds the matching Flysystem storage definitions (registered by
league/flysystem-bundle under the user-supplied storage name), and rewrites each definition
with setDecoratedService(...) so the correct decorator wraps it transparently. Users tag
their own storage definitions once; the pass does the rest.
Locked decision: DEC-FILE-MULTI — only services tagged tenancy.scoped are scoped.
Non-tagged Flysystem services bypass scoping entirely (the escape hatch for landlord-side
assets, public CDN directories, etc.).
Installation¶
The bundle treats league/flysystem-bundle as an optional dependency guarded by
interface_exists(\League\Flysystem\FilesystemOperator::class). Installing it is only
necessary when tenancy.filesystem.enabled: true — the bundle no-ops if it is absent.
For in-memory storage in tests:
Quick Start (prefix mode)¶
Prefix mode is the default (DEC-FILE-MODE). It requires no per-tenant configuration — a single
shared adapter is used and every Flysystem operation is transparently scoped to
tenant_{slug}/ for the active tenant.
Step 1 — Define a storage in config/packages/flysystem.yaml:
flysystem:
storages:
users.storage:
adapter: local
options:
directory: '%kernel.project_dir%/var/storage/users'
Step 2 — Tag it in config/services.yaml:
services:
users.storage:
tags:
- { name: tenancy.scoped, strategy: prefix, prefix_template: 'tenant_{slug}/' }
Step 3 — Enable the filesystem bootstrapper in config/packages/tenancy.yaml:
Step 4 — Inject the decorated storage in your controller or service:
use League\Flysystem\FilesystemOperator;
class UploadController extends AbstractController
{
public function __construct(
private readonly FilesystemOperator $usersStorage,
) {}
public function upload(Request $request): Response
{
$file = $request->files->get('upload');
// No slug prefix needed — FilesystemPrefixingDecorator handles it transparently.
$this->usersStorage->writeStream(
basename($file->getClientOriginalName()),
fopen($file->getRealPath(), 'r'),
);
// ...
}
}
The $usersStorage argument is autowired by league/flysystem-bundle's
registerAliasForArgument machinery (camelCased variable name → storage service ID). At
compile time FilesystemContractPass replaces the users.storage definition with a
FilesystemPrefixingDecorator wrapping the inner storage — the controller receives the
decorator, not the raw adapter.
A working end-to-end example lives in examples/saas/src/Controller/TenantUploadController.php
and the matching template in examples/saas/templates/upload/index.html.twig.
Per-tenant-adapter mode¶
Per-tenant-adapter mode routes each tenant's Flysystem operations to a separate adapter instance — a distinct S3 bucket, a per-tenant local directory on a different mount point, or an in-memory adapter (tests). Use this mode for regulated deployments where tenant data must be physically isolated rather than logically prefixed within a shared adapter. (DEC-FILE-MODE + DEC-FILE-CONFIG)
Configuration¶
Tag the storage with strategy: per_tenant_adapter:
# config/services.yaml
services:
tenant_buckets.storage:
tags:
- { name: tenancy.scoped, strategy: per_tenant_adapter }
Provide an adapter_dsn on the tenant entity via TenantFilesystemConfigTrait or a
manual implementation:
use Tenancy\Bundle\Filesystem\TenantFilesystemConfigTrait;
class AppTenant extends AbstractTenant
{
use TenantFilesystemConfigTrait;
}
Then seed each tenant's config:
Supported DSN schemes (Phase 24 scope):
| Scheme | Adapter |
|---|---|
local:// |
League\Flysystem\Local\LocalFilesystemAdapter |
memory:// |
League\Flysystem\InMemory\InMemoryFilesystemAdapter |
s3:// |
League\Flysystem\AwsS3V3\AwsS3V3Adapter |
Additional schemes can be registered via AdapterDsnParser::addScheme() in a custom
compiler pass. Unknown schemes throw UnsupportedAdapterDsnSchemeException at runtime.
Enable allow_per_tenant_adapter (default true; disable on shared-hosting deployments
where per-tenant S3 credentials are a security risk):
Tenant config shape¶
TenantFilesystemConfigTrait::getFilesystemConfig() returns:
?array{
prefix?: string, // override for prefix mode (defaults to 'tenant_{slug}/')
adapter_dsn?: string, // per_tenant_adapter DSN (e.g. 's3:///bucket?region=eu-central-1')
services?: string[], // reserved — no-op in v0.4; setting this key has no effect in the current release
}
When null is returned, prefix mode with the default template applies — the zero-config
path for new installs.
Configuration reference¶
All keys live under tenancy.filesystem.* in config/packages/tenancy.yaml.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
false |
Enable the filesystem bootstrapper. Set to true to activate. |
allow_per_tenant_adapter |
bool |
true |
Allow per_tenant_adapter strategy. Disable on shared-hosting deployments to prevent per-tenant adapter instantiation. |
prefix_template |
string |
'tenant_{slug}/' |
Template for prefix mode. {slug} is replaced with the active tenant's slug at call time. |
cache_size |
int |
32 |
Maximum number of per-tenant adapter instances kept in the LRU cache. Increase for workloads with many concurrent tenants; decrease to reduce memory pressure in constrained workers. |
Example showing all keys:
tenancy:
filesystem:
enabled: true
allow_per_tenant_adapter: true
prefix_template: 'tenant_{slug}/'
cache_size: 32
Exception handling¶
MissingFilesystemConfigException¶
Thrown when per_tenant_adapter mode is active and the resolved tenant's
filesystemConfig.adapter_dsn is null or missing.
Extends \LogicException, NOT \RuntimeException. This is intentional (DEC-FILE-EXCEPTION,
mirroring the Phase 23 WR-01 Messenger no-retry pattern): Symfony Messenger's default retry
strategy retries RuntimeException subclasses. A misconfigured tenant should not be
retried — it is a programmer or operator error (missing DSN in the database). The message goes
to the Dead Letter Queue (DLQ) immediately. Monitor your DLQ for this exception class.
UnsupportedAdapterDsnSchemeException¶
Thrown when per_tenant_adapter mode encounters an adapter_dsn whose scheme is not
registered in AdapterDsnParser. For example, ftp:///uploads would throw this exception
because Phase 24 ships only local://, memory://, and s3://.
Also extends \LogicException — same no-retry semantics.
FilesystemContractPass compile-time guards¶
Three errors are surfaced at container compile time (not at runtime):
- Bundle absent + enabled:
tenancy.filesystem.enabled: truebutleague/flysystem-bundleis not installed →LogicExceptionpointing at the install command. - Per-tenant-adapter forbidden: a service is tagged
strategy: per_tenant_adapterbuttenancy.filesystem.allow_per_tenant_adapterisfalse→LogicException. - Invalid strategy: a service is tagged with an unknown
strategyvalue (notprefixorper_tenant_adapter) →LogicException.
Trust boundary: path traversal¶
Application responsibility
The bundle treats all path arguments passed to $filesystem->write($path),
$filesystem->read($path), etc. as TRUSTED. If user-supplied values (e.g. upload
filenames, query parameters) reach a Flysystem call without sanitisation, a path-traversal
attack is possible.
The bundle does NOT sanitise path arguments. This is an application-level concern.
Recommended sanitisation for upload filenames:
// Minimal: strips directory traversal components.
$filename = basename($file->getClientOriginalName());
$filesystem->writeStream($filename, $stream);
For read paths from user input:
// Verify the resolved path stays within the tenant's own storage root.
// realpath() returns false for non-existent paths — check before using.
// Compare with a trailing separator on BOTH sides so a sibling directory whose
// name merely *starts with* the root (e.g. /srv/storage-evil for /srv/storage)
// cannot pass a naive str_starts_with($resolved, $realRoot) prefix check.
$realRoot = realpath($root);
$resolved = realpath($root . '/' . $userPath);
if (false === $realRoot || false === $resolved
|| !str_starts_with($resolved . \DIRECTORY_SEPARATOR, $realRoot . \DIRECTORY_SEPARATOR)) {
throw new \InvalidArgumentException('Path traversal detected.');
}
Flysystem 3's LocalFilesystemAdapter ships LocationGuard which prevents symlink traversal
(the DISALLOW_LINKS default mode), but does not block .. in path arguments at the
API level. The application MUST sanitise before calling the Flysystem API.
See the Flysystem security disclosure procedure for upstream guidance.
FAQ¶
Pitfall 1: My FilesystemOperator $defaultStorage argument doesn't resolve¶
Symptom: ServiceNotFoundException or autowiring failure on FilesystemOperator $defaultStorage.
Cause: league/flysystem-bundle registers storages under their user-supplied name (e.g.
default.storage), not a derived key like flysystem.filesystem.default.storage. The
bundle's registerAliasForArgument call creates a typed argument alias so FilesystemOperator
$defaultStorage resolves to default.storage. After FilesystemContractPass decorates it,
the alias still points at the outermost decorator.
Fix: Use the camelCased variable name matching the storage name. For users.storage use
FilesystemOperator $usersStorage.
Pitfall 2: listContents() return type errors¶
Symptom: PHPStan warns about wrong return type; foreach on the return value throws.
Cause: Flysystem 1.x returned array; Flysystem 3 returns
\League\Flysystem\DirectoryListing implements IteratorAggregate. Code written for
Flysystem 1.x that does $files = $fs->listContents(''); $files[0]->path() will fail.
Fix: Iterate with foreach or call ->toArray():
Pitfall 3: InMemoryFilesystemAdapter class not found in tests¶
Symptom: Error: Class "League\Flysystem\InMemory\InMemoryFilesystemAdapter" not found.
Cause: league/flysystem-memory is a separate Composer package — it is NOT transitively
required by league/flysystem even though its namespace looks like it should be.
Fix: composer require --dev league/flysystem-memory.
Pitfall 4: Cross-tenant data leak in long-running workers¶
Symptom: Tenant A's files appear under Tenant B's listing after a context switch.
Cause: A decorator that caches the prefix (or any tenant-specific value) in an instance property will retain that value across context switches in a Messenger worker.
Fix: The bundle's FilesystemPrefixingDecorator reads TenantContext live on every
call — no mutable instance state. If you write a custom decorator, follow the same pattern:
never memoize tenant-derived values. Use FilesystemBootstrapper::clear() (or the
TenantContextClearedListener) to flush the per-tenant adapter LRU.
Pitfall 5: Path traversal via user-supplied filenames¶
Symptom: A tenant uploads a file named ../../etc/passwd.
Fix: Apply basename() before writing; add a realpath() guard for explicit-path reads.
See Trust boundary: path traversal above.
Pitfall 6: Decorated storage bypasses autowiring¶
Symptom: A service that type-hints FilesystemOperator $usersStorage receives the raw
League\Flysystem\Filesystem instead of the FilesystemPrefixingDecorator.
Cause: ScopedStorageTaggingPass must run before FilesystemContractPass. In test
kernels, register ScopedStorageTaggingPass at PassConfig::TYPE_BEFORE_OPTIMIZATION
priority 10 (FilesystemContractPass runs at priority 0) so tags are attached before the
pass walks findTaggedServiceIds('tenancy.scoped').
Fix (test kernels only):
$container->addCompilerPass(
new ScopedStorageTaggingPass(),
\Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION,
10,
);
In production configurations, the user's services.yaml tag is read before compiler passes
run — ordering is not an issue.
Pitfall 7: Filesystem (concrete) vs FilesystemOperator (interface) confusion¶
Symptom: TypeError: Argument 1 expected Filesystem, FilesystemOperator given.
Cause: Decorators implemented against the concrete Filesystem class reject the
LazyFilesystem and other FilesystemOperator implementations the bundle may use.
Fix: Always type-hint the interface \League\Flysystem\FilesystemOperator, both for
the constructor's $inner parameter and for the service alias. The bundle's concrete
Filesystem satisfies the interface; so does any adapter-specific wrapper.
See also¶
- Configuration Reference —
tenancy.filesystem.*keys. - UPGRADE.md → 0.3 to 0.4 — the zero-BC-break adoption path for existing v0.3 projects.
- Mailer Bootstrapper — the structural twin; the filesystem bootstrapper follows the same lifecycle shape.
- Profiler Tab — Phase 29 (DOC-20) will add a Filesystem subsection to the WDT panel showing the active tenant's storage strategy, prefix, and LRU cache state.
- Shared Entities — the
#[Shared]sync model: landlord-side master records fanned out as tenant-side read-only copy to each tenant database. - PHPStan Extension — static-analysis rules that catch tenancy + filesystem misconfiguration at edit time.