Agora

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:

app/controllers/avatars_controller.ts
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:

OptionDefaultWhat it does
diskstorage default diskWhich Drive disk to write to.
keyPrefixattachments (or your attachmentKeyPrefix)Storage key prefix.
variants[]Image variants to generate eagerly (needs an ImageProcessor).
namethe file nameDisplay 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:

app/models/user.ts
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:

app/controllers/avatars_controller.ts
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 livesa MediaStore (in-memory / Lucid table)inline on a model column (JSON)
Cardinalitymany per owner, per collection (or single)one file per column
Ownerexplicit ownerType + ownerIdnone — the model is the owner
Conversions/variantseager or lazy, cached on the recordeager only, declared up front
MIME whitelist / orderingyes (collection policy)no
Best forgalleries, document sets, anything with policya 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

On this page