arxy/files
Arxy Files simplifies file management in PHP apps with FlySystem: store files anywhere, persist metadata in the database, and deduplicate uploads using MD5 checksums. Automatic upload/delete hooks and flexible naming strategies keep entities in sync.
The bundle splits responsibilities into four collaborators you will touch constantly: a Manager (upload/read/delete orchestration), a Storage (Flysystem adapter wrapper), a NamingStrategy (hash → path), and a Repository (metadata persistence). Learn those four and the rest is configuration.
1. Install and register
composer require arxy/files
Register Arxy\FilesBundle\ArxyFilesBundle in config/bundles.php. You need league/flysystem-bundle (or hand-wired Flysystem services) plus doctrine/orm.
2. Create the entity. The bundle ships an abstract Arxy\FilesBundle\Entity\File; you add the id mapping yourself:
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/** @ORM\Entity() @ORM\Table() */
class File extends \Arxy\FilesBundle\Entity\File
{
/** @ORM\Id @ORM\Column(type="integer") @ORM\GeneratedValue */
protected $id;
public function getId(): ?int { return $this->id; }
public function setId(?int $id): void { $this->id = $id; }
}
3. Create the repository — one trait, nothing else:
class FileRepository extends EntityRepository implements \Arxy\FilesBundle\Repository
{
use \Arxy\FilesBundle\Repository\ORM;
}
4. Configure a manager (the arxy_files tree is the fastest path; the manual-services approach in the README is for when you need several managers or non-standard wiring):
# config/packages/arxy_files.yaml
flysystem:
storages:
uploads:
adapter: 'local'
options: { directory: '%kernel.project_dir%/public/uploads' }
arxy_files:
managers:
public:
driver: orm
class: 'App\Entity\File'
storage: 'uploads'
naming_strategy: 'Arxy\FilesBundle\NamingStrategy\SplitHashStrategy'
repository: 'App\Repository\FileRepository'
5. First upload. The critical mental model: upload() only builds the entity and computes the target path. Bytes hit the filesystem in a Doctrine postPersist hook.
$file = $manager->upload($request->files->get('avatar')); // UploadedFile is an SplFileInfo
$user->setAvatar($file);
$em->persist($user);
$em->flush(); // ← now the file is actually written
Where to look first when something is off: Arxy\FilesBundle\Manager (the whole lifecycle lives here), Arxy\FilesBundle\Model\File (the contract your entity inherits), Storage\FlysystemStorage, and EventListener\DoctrineORMListener. Read those four classes before debugging any config.
First real use case to build: a user avatar. It exercises upload, dedup, naming, serving, and deletion in one flow, and it will surface the two-phase-write gotcha immediately.
Form-driven upload. FileType bridges Symfony's file input to the manager, so the form field's data is already a File entity:
$builder->add('image', \Arxy\FilesBundle\Form\Type\FileType::class, [
'required' => false,
'constraints' => [/* constraints on the resulting File entity */],
'input_options' => [
'attr' => ['accept' => 'image/*'],
'constraints' => [/* Symfony File constraints, run on the raw upload */],
],
]);
Keep the two constraint buckets separate: input_options.constraints validates the raw UploadedFile (size, mime); constraints validates the resulting entity. Validating mime only on the entity is too late — the bytes have already been hashed and staged.
Embeddable files on domain entities. When the file is really a column of News, not a first-class record, embed it:
class News
{
/** @ORM\Embedded(class=EmbeddableFile::class) */
private ?EmbeddableFile $image = null;
public function getImage(): ?File { return $this->image; }
public function setImage(?File $file): void { $this->image = $file; }
}
An embeddable has no id, which rules out IdToPathStrategy. Pair embeddables with SplitHashStrategy/UuidV5Strategy or PersistentPathStrategy so the path never depends on a database identity.
Serving files — three tiers, pick deliberately.
| Need | Approach |
|---|---|
| Authenticated / gated download | DownloadUtility::createResponse($file) in a controller |
| Custom filename, inline vs attachment, cache expiry | wrap in DownloadableFile: new DownloadableFile($file, 'invoice.pdf', false, new \DateTimeImmutable('+1 year')) |
| CDN / public bucket URL in an API payload | PathResolver::getPath($file) inside a serializer normalizer |
The normalizer pattern is the one to copy for JSON APIs:
public function normalize($object, $format = null, array $context = [])
{
$data = $this->objectNormalizer->normalize($object, $format, $context);
$data['url'] = $this->pathResolver->getPath($object); // join with CDN base if needed
return $data;
}
For image variants, decorate the same normalizer with LiipImagine\FileFilterPathResolver and loop FilterConfiguration::all() to emit a formats map (squared_thumbnail, large, …). This keeps the client from constructing URLs and lets you swap CDN/filter sets in one place.
Multiple managers for public vs private storage. Define two entries in arxy_files.managers (public, private) with different Flysystem storages, classes, and naming strategies. Since ManagerInterface only aliases one of them, inject with a named parameter or define explicit service ids — do not rely on the generic interface once you have more than one manager.
Naming strategy selection.
| Strategy | Path shape | Use when |
|---|---|---|
SplitHashStrategy |
098f6bcd/4621d373/.../098f... |
Default; no DB dependency, sharded dirs |
DateStrategy |
2021/05/17/<md5> |
You want date-based archival/retention |
UuidV5Strategy |
UUIDv5(namespace + md5) | Opaque paths, stable across deployments |
PersistentPathStrategy |
whatever is stored on the entity | Random UUIDv4 paths; requires PathAware |
AppendExtensionStrategy |
decorator, adds .jpg/.pdf |
Almost always |
DirectoryPrefixStrategy / NullDirectoryStrategy |
decorators | Multi-tenant prefixes, flat layouts |
Compose them: AppendExtensionStrategy(SplitHashStrategy) is the sane default, because a hash-only path gives browsers and CDNs no extension to reason about.
Custom extension points. Implement Arxy\FilesBundle\NamingStrategy for tenant-aware paths (acme/2024/05/<md5>), or implement Storage if you need signed S3 URLs / per-request credentials without going through Flysystem. Both are small interfaces — the manager takes them by constructor, so tests can substitute an in-memory Flysystem adapter:
flysystem:
storages:
test: { adapter: 'memory' }
Nothing is on disk until flush(). upload() returns a fully-populated entity whose path exists nowhere yet. If the flush fails, you have no file and no row — but you also have no cleanup problem. Conversely, if the DB commit succeeds and the filesystem write in postPersist throws, you get a row pointing at a missing file. Wrap flush in try/catch and treat filesystem failures as fatal, logged and surfaced.
Only postPersist and preRemove are registered in the documented setup. That means replacing a file on an already-persisted entity may never be written, because no postUpdate fires. Verify against DoctrineORMListener in your installed version; if it holds, register postUpdate yourself or force a delete+persist cycle when swapping files.
Dedup plus preRemove is the sharpest edge. Identical content maps to the same md5/namespace/path, and dedup "reuses" the file. Two owners can therefore reference the same underlying object. Deleting one owner triggers file removal on preRemove — check whether that unlinks content another row still points at. If you rely on dedup in production, add reference counting (or a soft-delete flag on File) rather than trusting the hook.
IdToPathStrategy cannot be used with EmbeddableFile. No id, no path. Also avoid it for prePersist-time path computation if your storage layer needs the path before the id is assigned.
The extension comes from the client. AppendExtensionStrategy appends the original filename's extension — sanitize/whitelist it, or the path inherits whatever the browser sent.
read() loads the whole file into memory. For anything user-supplied, use readStream() and hand the handle to a StreamedResponse. Same for DownloadUtility if you enable inline serving of large media.
PathResolver returns a storage path, not an absolute URL. With a local adapter under public/uploads you still need to prepend the base URL; with a CDN you need the domain. Centralize that in the normalizer, not in templates.
Path resolution bypasses authorization. Any endpoint that emits PathResolver URLs is effectively public if the storage is public. Private documents must be served exclusively through a controller using DownloadUtility with an access check before it.
Set cache headers explicitly. DownloadableFile accepts a cache-expiry DateTimeImmutable — without it you get no useful Cache-Control/ETag, and every download re-streams from storage.
Don't mix the arxy_files config tree with the manual service definitions. Pick one. The manual block exists for custom storages/strategies; duplicating services leads to two managers with subtly different naming strategies and files landing in two directory trees.
Debugging a missing file: check (a) did flush() run, (b) is the naming strategy the one you think (dump $file->getPathname()), (c) is the storage name in arxy_files.managers spelled exactly like the Flysystem storage key, (d) is the Doctrine listener actually registered for the event you expect (bin/console debug:event-dispatcher).
Maturity risk. 18 stars, 0 dependents, essentially a single maintainer, and no upgrade guide. Pin the version in composer.json, read Manager and the listener source before upgrading, and keep your integration behind one service of your own so swapping to a hand-rolled Flysystem + Doctrine solution stays a contained change. The package description mentions ODM documents, but the README states only Doctrine ORM is supported — do not plan on MongoDB ODM.
How can I help you explore Laravel packages today?