File uploads are managed by @jrmc/adonis-attachment. A Lucid model declares an attachment field with @attachment({...}); uploads produce a stored file plus derived variants (thumbnails, previews, formats) driven by named converters in config/attachment.ts. Actions accept a MultipartFile from a validated form and call attachmentManager.createFromFile(file) to assign it. Storage is Drive (fs locally, S3-family in prod). URLs are pre-computed on read via a static model helper — always call it before serializing to Inertia so the frontend gets a real URL, not undefined.
Rules
- Field on model: decorate with
@attachment({ preComputeUrl: false, variants: [...] })and type asAttachment:@attachment({ preComputeUrl: false, variants: ['thumbnail'] }) declare avatar: AttachmentpreComputeUrl: falsekeeps model reads cheap — URLs are computed on demand by the static helper. - Converters live in
config/attachment.tsas a keyed record. Each converter declares options (resize, format, etc.). The variant names in@attachment({ variants: [...] })reference those keys. - Declaration merge happens in the config file's
declare module '@jrmc/adonis-attachment'block — that's how TypeScript knowsvariants: ['thumbnail']is a valid key. - Validate uploads with VineJS
vine.file({...}):avatar: vine.file({ extnames: ['png', 'jpg', 'jpeg', 'gif'], size: 1 * 1024 * 1024 }).nullable() - Persist inside an action — the action takes a
MultipartFile(notHttpContext) and delegates to the manager:
See [[actions-events]] for the "no HttpContext in actions" rule.if (input.avatar) { input.target.avatar = await attachmentManager.createFromFile(input.avatar) } - Pre-compute URLs before serialization. Call the model's static helper on the loaded row (or list) before running the Transformer:
The Transformer variant reads the pre-computed URL from the loaded variant.await User.preComputeUrls(users) return inertia.render('users/index', { users: UserTransformer.paginate(users, meta).useVariant('forList'), }) - Fallback URL: Transformers usually resolve
variant.url ?? this.resource.<remoteUrl>so an externally-hosted URL (social-auth avatar, S3 direct link) works when no local upload exists. - Drive config picks the disk (
fs,s3,gcs, …). TheDRIVE_DISKenv var selects it.
Repo refs
@attachmentfield +preComputeUrlson read:app/users/models/user.ts(avatar).- Converters (resize / webp):
config/attachment.ts.
Doc refs
- Package README — https://github.com/batosai/adonis-attachment
- AdonisJS Drive — https://docs.adonisjs.com/guides/digging-deeper/drive
- VineJS file validation — https://vinejs.dev/docs/types/file
Workflow
Add an attachment field to a model
- Add a converter in
config/attachment.tsif the format/size you need doesn't exist yet:converters: { thumbnail: { options: { resize: 300, format: 'webp' } }, hero: { options: { resize: 1600, format: 'webp' } }, }, - Decorate the model column:
@attachment({ preComputeUrl: false, variants: ['hero'] }) declare cover: Attachment - Migration: add a JSON column for the attachment metadata:
table.json('cover').nullable() - Static
preComputeUrlson the model — mirror the pattern used elsewhere:static async preComputeUrls(rows: Post | Post[]) { if (Array.isArray(rows)) { await Promise.all(rows.map((r) => this.preComputeUrls(r))) return } const hero = rows.cover?.getVariant('hero') if (hero) await attachmentManager.computeUrl(hero) } - Validator:
cover: vine.file({ extnames: [...], size: 5 * 1024 * 1024 }).nullable(). - Action:
input.target.cover = await attachmentManager.createFromFile(input.cover). - Transformer: expose the URL via a helper (
coverUrl()) so consumers don't dig into the variant graph. - Controller: call
Model.preComputeUrls(...)on the loaded rows before running the Transformer.
Change a converter
Edit config/attachment.ts. Existing uploads keep their old variant unless you re-process — there's no automatic re-render on config change.
Testing
- Use a real file fixture (small PNG) with the API client's
.file(...)method. - Fake Drive:
drive.fake()in group setup — see [[testing]]. - Assert the DB row has the attachment JSON populated; assert file existence via
disk.assertExists(...).
Anti-patterns
- ❌ Skipping
preComputeUrlsbefore serialization — the Transformer returnsundefinedfor the variant URL and the frontend breaks. - ❌ Setting
preComputeUrl: trueon the decorator to "save code" — every model read now does filesystem I/O, even when the row isn't serialized. - ❌ Reading
user.avatar.urldirectly in a React component — always through a Transformer helper. Otherwise the shape drifts. - ❌ Storing the raw uploaded filename or path outside
@attachment— the package owns storage and filenames. - ❌ Validator without a
sizelimit — DoS via huge uploads. - ❌ Bypassing
attachmentManager.createFromFile(...)and stuffing theMultipartFileinto the model field directly.
Related skills
[[crud]] · [[actions-events]] · [[testing]] · [[inertia]]