The Media Library
Every file a business uploads in Zerp — employee documents, contract
attachments, project files, lead/deal files, candidate resumes, support
ticket attachments, avatars — goes through one shared system: the Media
Library, backed by App\Services\MediaAttachmentService.
Why one shared service
Before this existed, each module handled its own uploads with slightly
different conventions (different disks, different path shapes, inconsistent
delete-on-remove behavior, some modules silently never deleting old files
at all). MediaAttachmentService gives every module the same four
operations instead of reinventing upload/delete per feature:
App\Services\MediaAttachmentService::upload(...)
App\Services\MediaAttachmentService::resolveOrBackfill(...)
App\Services\MediaAttachmentService::ensureDirectory(...)
App\Services\MediaAttachmentService::deleteMedia(...)
upload()— stores anUploadedFile, creates aMediarow, links it to a directory/collection/owner.resolveOrBackfill()— idempotent link-or-create: given an existing path-based reference (from before a table had amedia_idcolumn), finds or creates the matchingMediarow without duplicating it on repeat runs. Used by the backfill command below.ensureDirectory()— gets-or-creates aMediaDirectory(e.g. one per module/collection) to keep the library organized.deleteMedia()— deletes both theMediarow and the underlying file.
Storage layout is deliberately flat
Files are stored flat — media/{file_name}, occasionally with one extra
subpath segment (e.g. employee_documents/xxx.pdf) — rather than using
Spatie Media Library's default per-model/per-collection nested directory
structure. This is intentional: 35+ MediaPicker frontend consumers across
every module reconstruct file URLs assuming this flat shape. Spatie's real
addMedia() pipeline and conversions, and the registered MediaPathGenerator
class, exist in the dependency tree but are not used — don't wire a new
feature directly against Spatie's native API, go through
MediaAttachmentService instead so URL construction stays consistent.
Migrating an existing table to the Media Library
If you're touching a table that stores a raw file path (or a JSON array of
paths) instead of a media_id foreign key:
- Add a
media_id(or{field}_media_id) column + relation to the model. - Wire the controller's store/update/destroy to call
MediaAttachmentService::upload()/deleteMedia(). - Backfill existing rows with
php artisan media:backfill <source>(seeapp/Console/Commands/BackfillMediaAttachments.php) — it's idempotent and supports--dry-run, and has a declarativesources()map you add an entry to (path-column sources) orjsonArraySources()(for a column storing a JSON array of paths, e.g. support-ticket attachments).
A caveat if you're touching JSON array attachment columns
At least one pre-existing module (support-ticket) casts an attachments
column as 'array' in Eloquent but its controllers were calling
json_encode() on the value before passing it to create()/update() —
double-encoding it. If you add code that reads that column back through the
model's cast accessor, check for this rather than assuming the column is
clean JSON; linking through MediaAttachmentService from the pre-encode
PHP array already in scope at the call site avoids the corrupted cast
value entirely.