Media
A media library for AdonisJS — attach files to your entities, organize them into collections, and generate image conversions, all on top of @adonisjs/drive. The spatie/laravel-media-library feel, for Adonis.
@adonis-agora/media gives AdonisJS the spatie/laravel-media-library feel: attach files to your entities, organize them into named collections (with a MIME whitelist, single-file replacement, and ordering), and generate image conversions — eagerly on upload or lazily on first access. Storage itself is delegated entirely to @adonisjs/drive — either major, ^3.0.0 or ^4.0.0 — so you reuse your existing local / s3 / gcs disks. This package never reimplements disk drivers.
The problem it solves
Handling uploads in a real app means more than calling file.move(). You end up rebuilding the same machinery in every project: where does this file live, which MIME types are allowed here, is this the kind of field that holds one file (an avatar) or many (a gallery), how do I produce a thumbnail, and how do I clean everything up when the row is deleted. @adonis-agora/media collapses that into two small layers:
- A media library —
attacha file to an entity in a named collection,list/find/deleteit, and ask for aurl. Collections carry the policy (allowed MIME types, single-vs-many, conversions); the library enforces it. - Column attachments — the adonis-attachment style, where a file lives directly on a model column as a JSON value (plus any image variants). No extra table, no owner — just a field.
Both sit over the same Drive disks and the same image processor, so you pick whichever shape fits each field and the storage story is identical.
Built on Drive, with pluggable backends
The library talks to storage through a tiny structural Disk contract that a real @adonisjs/drive disk satisfies directly — so it never imports Drive and Drive stays a peer dependency you already have. Two more seams are pluggable and lazy:
- The
MediaStorepersists the metadata rows. Ships with an in-memory store (single-process, for tests and scratch apps) and a Lucid store (SQLite / Postgres / MySQL, with a published migration). The Lucid driver only imports@adonisjs/lucidwhen you actually select it. - The
ImageProcessordoes the resizing / cropping / format conversion. Ships with a sharp processor;sharpis an optional peer imported only when conversions are configured.
This is the Agora config idiom: drivers live in the core package, you select them by name with a defineConfig + factory map, and each heavy peer loads lazily only when chosen.
Quickstart
The minimal loop — install Drive, install and configure media, attach a file, get a URL.
Install the storage backend and the library, then run the configure codemod:
node ace add @adonisjs/drive # storage backend (required)
npm i @adonis-agora/media
node ace configure @adonis-agora/mediaconfigure registers both providers (the library and the embedded management console), publishes config/media.ts and config/media_dashboard.ts, and publishes two Lucid migrations — the media table and the upload-session table. Delete the migrations you don't need; the in-memory stores need none.
Import the media service and attach an uploaded file to an entity's collection from a controller:
import { createReadStream } from 'node:fs'
import media from '@adonis-agora/media/services/main'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostImagesController {
async store({ request, response, params }: HttpContext) {
const image = request.file('image', { size: '5mb', extnames: ['jpg', 'png', 'webp'] })
if (!image) return response.badRequest({ error: 'An "image" file is required' })
const record = await media.library.attach({
ownerType: 'Post',
ownerId: params.id,
collection: 'gallery',
fileName: image.clientName,
mimeType: image.type!,
contents: createReadStream(image.tmpPath!), // Buffer | Readable
})
return response.created({ id: record.id })
}
}Read it back — the original or a (lazily generated, then cached) conversion:
await media.library.url(record.id) // public url of the original
await media.library.url(record.id, 'thumb') // generated on first call, then cached
await media.library.list('Post', params.id, 'gallery')
await media.library.delete(record.id) // removes the original AND every conversionFor the full walkthrough — including the optional for(owner) binding and column attachments — see Getting Started.
What you get
- Collections with policy. A collection is a named bucket with an optional MIME whitelist, an optional
single: true(attaching replaces the existing file), per-collection conversions, and a per-collection disk override. - Image conversions, eager or lazy. Declare presets (
width/height/fit/format/quality); eager ones are generated onattach, the rest on the firsturl(id, name)and cached on the record so they're produced once. - Two attachment shapes over one storage layer. The owner-collection
MediaLibraryand the columnAttachmentManager, sharing the same disks and image processor. - Reuse your Drive disks.
local,s3,gcs— whatever you configured inconfig/drive.ts. The default disk, a per-collection disk, and a per-attachdisk override all compose. - Pluggable persistence. An in-memory store for tests and a portable Lucid store with a published migration; bring your own
MediaStoreby implementing one interface. - A testing kit. In-memory doubles for all three SPIs (
InMemoryMediaStore,InMemoryDisk,FakeImageProcessor) so you can exercise the library with no disk, database, or sharp. - Structural diagnostics. Lifecycle events on
agora:media:*via a global slot, captured automatically by Telescope when present — and an inert no-op when it isn't.
Where to go next
Getting Started
Install Drive, configure the library, attach your first file, and resolve URLs — step by step.
Configuration
Every key on defineConfig — disk, store/stores, imageProcessor, collections, the diagnostics toggle.
Collections & Conversions
MIME whitelist, single-file replace, ordering, and image presets — eager vs lazy generation.
Attachments
Column attachments (adonis-attachment style): a file (and variants) stored directly on a model column.
Single-file store
The avatar-style seam — replace this owner's one file, get a URL, with no hard dependency on media.
Upload modes
Large-file uploads — proxy (through your app) and direct-S3 multipart (straight to the bucket).
Resumable / TUS
Chunked, resumable uploads over the tus protocol, with a pluggable session store.
Direct upload policy
The app-injected seam deciding an upload's key, what a completed upload becomes, and how failures read as HTTP.
S3 disk
The bundled disks.s3() driver — extended operations, native multipart, presigned URLs.
React client
@adonis-agora/media-react — useMediaUpload, MediaUploader, and a framework-free upload client.
Dashboard
Embedded management console — browse buckets, watch uploads, copy/move/delete objects. No separate install.
Console authentication
The console's built-in session-cookie login — mint from your app's auth, or a login screen of its own.
Stores & Processors
The Lucid store and its migration, the sharp processor, the Disk contract, and writing your own driver.
Telescope
mediaTelescopeExtension — a Media overview dashboard built from the agora:media:* events.
Errors
Every exported error class, its stable code, and the properties it carries — branch on a code, never a message.
Testing
Drive the library with in-memory doubles — no disk, no database, no sharp.
Roadmap
What the library covers today, and what is still intentionally deferred.