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

Uploadable Laravel Package

ozznest/uploadable

A Laravel package for attaching file uploads to Eloquent models. Handle validation, storage, and cleanup through a simple trait, keeping upload logic out of controllers and models.

View on GitHub
Deep Wiki
Context7

Getting Started

First, calibrate expectations. ozznest/uploadable is a Symfony bundle (UploadableBundle), not a Laravel package. It ships a Doctrine event listener that moves UploadedFile instances onto disk and writes the resulting path into an entity property. Composer will happily install it into a Laravel app, but nothing will bootstrap: there is no Laravel service provider, and the file-moving logic lives in a Doctrine subscriber registered through Symfony's DI container.

That leaves you with two legitimate paths:

  1. If you're actually in a Symfony/Doctrine codebase — install and use it directly.
  2. If you're in Laravel (the far more likely case) — treat the repo as a reference implementation. The useful part is one listener class plus a trait; porting it to an Eloquent trait + model events is a half-day job and gives you a dependency you control.

Given 0 stars and 0 dependents, assume the package is unmaintained. Read it before trusting it.

Minimal steps (Symfony)

composer require ozznest/uploadable

Register the bundle — config/bundles.php on Symfony 4+:

return [
    // ...
    Ozznest\UploadableBundle\UploadableBundle::class => ['all' => true],
];

On Symfony 3.4 and below it goes in app/AppKernel.php instead. This registration API changed between major versions, so check the bundle's composer.json symfony/symfony constraint before anything else.

Then attach the upload behavior to an entity and point the bundle at a directory:

# config/packages/uploadable.yaml — verify key names against the DI extension
uploadable:
    upload_dir: '%kernel.project_dir%/public/uploads'
    upload_path: '/uploads'

Where to look first

Read in this order; it's short and the bundle is small:

  1. src/DependencyInjection/*Extension.php — the actual source of truth for config keys. Docs (if any) are usually stale; the extension is not.
  2. Resources/config/services.* — shows which listener is registered and at what priority.
  3. The Doctrine event subscriber that performs the move() — this is the whole package.
  4. The trait / annotation the entity uses, to see the expected property names.

First use case

A Product entity with a single image:

class Product
{
    use UploadableTrait; // or configure per-property per the bundle's README

    private ?string $image = null;
}

Form:

$builder->add('image', FileType::class, ['required' => false, 'mapped' => false]);

Controller:

$product->setImage($request->files->get('product')['image']);
$em->persist($product);
$em->flush();

If the file appears in public/uploads and the path is in the column, you've validated the whole path end to end. Do that before wiring anything else.

Implementation Patterns

Trait-on-entity, not inheritance. The bundle's contract is almost certainly "add this trait, get an upload property." Keep it on the entity rather than an abstract base class — Doctrine's inheritance mapping makes base classes with file columns awkward, and you'll want multiple entities with different upload sets.

Multiple upload properties on one entity. Each needs its own mapping entry and its own setter call before flush(). Don't assume the listener handles more than one file per entity; verify in the subscriber. If it doesn't, register one listener instance per property in a service definition rather than forking the class.

Form + lifecycle is the happy path. The intended flow is FileType → setter → prePersist/preUpdate moves the file → column gets the path. This is the pattern to copy. Anything else is a workaround.

Programmatic uploads (API, imports, jobs). Skip the form entirely and set the UploadedFile directly, then flush:

$product->setImage(new UploadedFile($tmpPath, $filename, $mime, null, true));
$em->flush();

The listener only cares that a UploadedFile is present on the entity when the event fires. This works because the bundle hooks Doctrine, not the form component.

Replacing an existing file. The most common real bug: an update that doesn't include a new file must not null out the column. Guard in the setter:

public function setImage(?UploadedFile $file): self
{
    if ($file !== null) {
        $this->image = $file; // listener consumes this
    }
    return $this;
}

Deleting files. The bundle moves files in; it very likely does not clean up on entity removal. Add a preRemove listener of your own, or accept orphaned files and sweep them with a scheduled command comparing directory contents to the database. The sweep is more robust anyway — it catches failed flushes too.

Naming strategy. If the bundle exposes a namer/callback, use it. If not, wrap the incoming UploadedFile's original name with a UUID before setting it on the entity — collisions in a flat upload directory are otherwise guaranteed at any real volume.

Laravel port pattern (the pattern most relevant here). If you're rebuilding this for Eloquent, the shape is:

trait HasUploads
{
    public static function bootHasUploads(): void
    {
        static::saving(function ($model) { $model->storePendingUploads(); });
        static::deleted(function ($model) { $model->deleteStoredUploads(); });
    }
}

with a $uploads = ['image' => 'products/images'] map on the model, Storage::disk('public')->putFile(...) to move, and the resulting path written back before the save completes. Do the move in saving (before the row write) so a failed insert leaves an orphan file rather than a row pointing at nothing — orphans are cheap to sweep; broken paths break pages.

Gotchas and Tips

The package is effectively unmaintained. Zero stars, zero dependents, no CI badge. Treat the source as a copy-paste reference, not a dependency. If you do require it, pin the exact version and expect to own the code when Symfony's next major lands.

Bundle registration is version-sensitive. AppKernel::registerBundles() vs config/bundles.php vs Symfony 6's removal of most bundle config. Check the composer.json constraints before spending time on config — mismatch here is the #1 reason a 0-star bundle "doesn't work."

Doctrine lifecycle timing. The listener fires on prePersist/preUpdate, which happens inside flush(). Consequences:

  • If you set the file and never flush, no file moves. No error, just silence.
  • If flush() throws after the file was moved, you get an orphaned file.
  • If the entity isn't managed (detached, or persisted via a raw query builder), the listener never runs.

Debugging checklist when nothing happens:

  1. Is the entity actually managed by the EntityManager?
  2. Is the listener registered? php bin/console debug:event-dispatcher (Symfony 4.3+) shows Doctrine events.
  3. Is the property the listener expects actually named what it expects? A trait with a hardcoded property name is the usual culprit.
  4. Are you passing a Symfony\Component\HttpFoundation\File\UploadedFile, or a SymfonyUploadedFile / Illuminate\Http\UploadedFile from Laravel? Type hints don't cross frameworks.

Config path quirks. upload_dir is almost always filesystem absolute (%kernel.project_dir%/…) while upload_path is the web-relative URL prefix. Mixing them up produces entities whose column contains /var/www/..., which renders as broken images and looks like a template bug. Store only the relative path in the database.

Symlinks in public/. If the bundle writes to var/uploads and you serve via public/uploads, you need php bin/console assets:install or a manual symlink depending on your setup. Files present on disk but 404ing in the browser is this, nine times out of ten.

Validate outside the bundle. There is no meaningful validation in a package this small. Put File constraints with mimeTypes and maxSize on the entity/form. Never trust the extension the client sent.

Extension points worth knowing. The listener is a normal DI service — you can override it by alias, or decorate it, without forking. If you need custom naming, custom storage (S3), or thumbnails, that decoration point is where to do it. If the bundle doesn't expose a namer interface, decoration is your only clean hook.

Don't put this in a Laravel app and hope. No service provider means no config, no Storage integration, and no path helpers. The value of this repo to a Laravel developer is roughly 150 lines of event-listener logic and a reminder that file uploads belong in a trait with model events — both of which you can write yourself with better error handling in less time than debugging an unmaintained Symfony bundle.

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.
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings