Attachments
Column attachments — the adonis-attachment style, where a file (and its image variants) lives directly on a model column as a JSON value, over the same Drive disks and image processor as the media library.
The MediaLibrary is the spatie media-library shape: files belong to an owner and live in a separate metadata table. The AttachmentManager is the other common shape — the adonis-attachment style, where a single file lives directly on a model column as a JSON value, with no owner and no extra table. Both run over the same Drive disks and the same image processor; you pick whichever fits each field.
Reach for an attachment when a model simply has a file: a user's avatar, a product's hero image, a document's PDF. Reach for the media library when an entity has a collection of files with policy and ordering.
The Attachment value object
An Attachment is a pure, JSON-serializable value object. It carries the disk, path, size, MIME type, any image variants, and a free-form meta bag — and nothing else. It has no methods that touch storage; URL resolution and deletion go through the AttachmentManager (which holds the disks).
interface AttachmentData {
name: string
disk: string
path: string
size: number
mimeType: string
variants: Record<string, { disk: string; path: string; size: number; mimeType: string }>
meta: Record<string, unknown>
}Because it serializes cleanly, you store it on a JSON column and rebuild it on read:
attachment.toJSON() // → AttachmentData (what you persist)
Attachment.fromJSON(post.avatar) // → Attachment | null (null for null/undefined)Creating an attachment
createFromFile(input, options?) uploads the bytes (and any variants), then returns the Attachment you assign to a column. In a controller you take the uploaded file, stream its tmp bytes as contents, and persist the resulting value object on the model:
import { createReadStream } from 'node:fs'
import User from '#models/user'
import media from '@adonis-agora/media/services/main'
import type { HttpContext } from '@adonisjs/core/http'
export default class AvatarsController {
async update({ request, response, auth }: HttpContext) {
const user = auth.getUserOrFail()
const avatar = request.file('avatar', {
size: '2mb',
extnames: ['jpg', 'jpeg', 'png', 'webp'],
})
if (!avatar) return response.badRequest({ error: 'An "avatar" file is required' })
const att = await media.attachments.createFromFile(
{
fileName: avatar.clientName,
mimeType: avatar.type!,
contents: createReadStream(avatar.tmpPath!), // Buffer | Readable
},
{ variants: [{ name: 'thumb', width: 100 }] },
)
user.avatarData = att.toJSON() // store on a JSON column
await user.save()
return response.ok(user)
}
}The input is { fileName, mimeType, contents, size? }. The options are all optional:
| Option | Default | What it does |
|---|---|---|
disk | storage default disk | Which Drive disk to write to. |
keyPrefix | attachments (or your attachmentKeyPrefix) | Storage key prefix. |
variants | [] | Image variants to generate eagerly (needs an ImageProcessor). |
name | the file name | Display name stored on the attachment. |
meta | {} | Free-form metadata bag stored on the attachment. |
The file lands at ${keyPrefix}/${id}/${fileName} (with a generated id), and variants at ${keyPrefix}/${id}/variants/${name}.${format}. fileName (typically a client-supplied name like avatar.clientName above) must be a single safe path segment — a traversal attempt or an absolute path throws UnsafeFileNameError before anything is written.
Variants are eager
Unlike the media library's lazy conversions, attachment variants are always generated eagerly inside createFromFile. There's no per-attachment record to cache a lazily-generated variant onto, so you declare exactly the variants you want up front. They use the same ConversionPreset shape (width / height / fit / format / quality) and the same image processor.
If you request variants but no imageProcessor is configured, createFromFile throws — configure processors.sharp() (or your own) in config/media.ts first.
Resolving URLs
const att = Attachment.fromJSON(user.avatarData)!
await media.attachments.url(att) // the original
await media.attachments.url(att, 'thumb') // a named variant
await media.attachments.signedUrl(att, '30m') // signed, expiring (s3/gcs)
await media.attachments.signedUrl(att, '1h', { variant: 'thumb' }) // signed variant URL
// force a download with a chosen filename (a response header baked into the URL)
await media.attachments.signedUrl(att, '1h', {
contentDisposition: `attachment; filename="${att.name}"`,
})Asking for a variant that wasn't generated throws (Attachment has no variant "<name>"), so resolve only the variants you created.
signedUrl options
The third argument to signedUrl is an AttachmentSignedUrlOptions object. variant signs a named variant instead of the original; everything else — contentType, contentDisposition — is a response header the presigner bakes into the URL, applying to whoever follows the link rather than to this call. Use contentDisposition: 'attachment; filename="…"' to force a named download.
Deleting
delete removes the original and every variant from storage. It does not touch your model column — clear that yourself after:
await media.attachments.delete(att)
user.avatarData = null
await user.save()A model-column pattern
A clean way to use attachments on a Lucid model is to (de)serialize in a @column consumer or a small accessor. The attachment is just JSON, so any JSON column works:
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { Attachment, type AttachmentData } from '@adonis-agora/media'
export default class User extends BaseModel {
@column({
prepare: (v) => (v ? JSON.stringify(v) : v),
consume: (v) => (typeof v === 'string' ? JSON.parse(v) : v),
})
declare avatarData: AttachmentData | null
get avatar(): Attachment | null {
return Attachment.fromJSON(this.avatarData)
}
}Then a controller reads it back through the avatar accessor — resolve a variant URL for the response, or clear the column on delete:
async show({ response, auth }: HttpContext) {
const user = auth.getUserOrFail()
const url = user.avatar ? await media.attachments.url(user.avatar, 'thumb') : null
return response.ok({ avatarUrl: url })
}
async destroy({ response, auth }: HttpContext) {
const user = auth.getUserOrFail()
if (user.avatar) {
await media.attachments.delete(user.avatar)
user.avatarData = null
await user.save()
}
return response.noContent()
}Library vs attachment — which to use
MediaLibrary (collections) | AttachmentManager (column) | |
|---|---|---|
| Where metadata lives | a MediaStore (in-memory / Lucid table) | inline on a model column (JSON) |
| Cardinality | many per owner, per collection (or single) | one file per column |
| Owner | explicit ownerType + ownerId | none — the model is the owner |
| Conversions/variants | eager or lazy, cached on the record | eager only, declared up front |
| MIME whitelist / ordering | yes (collection policy) | no |
| Best for | galleries, document sets, anything with policy | a single avatar / hero / file field |
Both share the same disks, the same image processor, and the same diagnostics — so mixing them in one app is normal and cheap.
Next steps
- Collections & Conversions — the owner-collection alternative
- Configuration — the
attachmentKeyPrefixkey - Testing — exercise attachments with in-memory disks and the fake processor
Transformers & HLS
Pluggable content transformations — turn a stored media into derived artifacts (an HLS package, an extracted audio track) or pure metadata (a probe), persisted as named conversions. Ships with a mediabunny-backed HLS transformer and a metadata probe, plus an HLS-aware delivery handler.
Single-File Store
@adonis-agora/media/single-file — a tiny seam for avatar-style uploads, so another package can delegate 'replace this owner's one file and give me a URL' to media without taking a hard dependency on it.