Attachment
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 as Attachment:@attachment({ preComputeUrl: false, variants: ['thumbnail'] })
declare avatar: Attachment
preComputeUrl: false keeps model reads cheap — URLs are computed on demand by the static helper.
- Converters live in
config/attachment.ts as 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 knows variants: ['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 (not HttpContext) and delegates to the manager:if (input.avatar) {
input.target.avatar = await attachmentManager.createFromFile(input.avatar)
}
See [[actions-events]] for the "no HttpContext in actions" rule.
- Pre-compute URLs before serialization. Call the model's static helper on the loaded row (or list) before running the Transformer:
await User.preComputeUrls(users)
return inertia.render('users/index', {
users: UserTransformer.paginate(users, meta).useVariant('forList'),
})
The Transformer variant reads the pre-computed URL from the loaded variant.
- 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, …). The DRIVE_DISK env var selects it.
Repo refs
@attachment field + preComputeUrls on read: app/users/models/user.ts (avatar).
- Converters (resize / webp):
config/attachment.ts.
Doc refs
Workflow
Add an attachment field to a model
- Add a converter in
config/attachment.ts if 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
preComputeUrls on 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
preComputeUrls before serialization — the Transformer returns undefined for the variant URL and the frontend breaks.
- ❌ Setting
preComputeUrl: true on the decorator to "save code" — every model read now does filesystem I/O, even when the row isn't serialized.
- ❌ Reading
user.avatar.url directly 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
size limit — DoS via huge uploads.
- ❌ Bypassing
attachmentManager.createFromFile(...) and stuffing the MultipartFile into the model field directly.
Related skills
[[crud]] · [[actions-events]] · [[testing]] · [[inertia]]
1---2name: attachment3description: File uploads with derived variants in an AdonisJS app via `@jrmc/adonis-attachment`. `@attachment()` decorator on a Lucid model + converters config drives image resize/reformat (e.g. webp thumbnails). URLs are pre-computed on read via a model helper. Use when adding an upload field, changing a converter, testing attachment code, or debugging avatar URLs. Trigger on: "file upload", "avatar", "attachment", "thumbnail", "@attachment", "adonis-attachment".4license: MIT5---67# Attachment89File uploads are managed by [`@jrmc/adonis-attachment`](https://github.com/batosai/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`.1011## Rules1213- **Field on model**: decorate with `@attachment({ preComputeUrl: false, variants: [...] })` and type as `Attachment`:14 ```ts15 @attachment({ preComputeUrl: false, variants: ['thumbnail'] })16 declare avatar: Attachment17 ```18 `preComputeUrl: false` keeps model reads cheap — URLs are computed on demand by the static helper.19- **Converters** live in `config/attachment.ts` as a keyed record. Each converter declares options (resize, format, etc.). The variant names in `@attachment({ variants: [...] })` reference those keys.20- **Declaration merge** happens in the config file's `declare module '@jrmc/adonis-attachment'` block — that's how TypeScript knows `variants: ['thumbnail']` is a valid key.21- **Validate uploads** with VineJS `vine.file({...})`:22 ```ts23 avatar: vine.file({ extnames: ['png', 'jpg', 'jpeg', 'gif'], size: 1 * 1024 * 1024 }).nullable()24 ```25- **Persist inside an action** — the action takes a `MultipartFile` (not `HttpContext`) and delegates to the manager:26 ```ts27 if (input.avatar) {28 input.target.avatar = await attachmentManager.createFromFile(input.avatar)29 }30 ```31 See [[actions-events]] for the "no HttpContext in actions" rule.32- **Pre-compute URLs before serialization**. Call the model's static helper on the loaded row (or list) before running the Transformer:33 ```ts34 await User.preComputeUrls(users)35 return inertia.render('users/index', {36 users: UserTransformer.paginate(users, meta).useVariant('forList'),37 })38 ```39 The Transformer variant reads the pre-computed URL from the loaded variant.40- **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.41- **Drive config** picks the disk (`fs`, `s3`, `gcs`, …). The `DRIVE_DISK` env var selects it.4243## Repo refs4445- `@attachment` field + `preComputeUrls` on read: `app/users/models/user.ts` (avatar).46- Converters (resize / webp): `config/attachment.ts`.4748## Doc refs4950- Package README — https://github.com/batosai/adonis-attachment51- AdonisJS Drive — https://docs.adonisjs.com/guides/digging-deeper/drive52- VineJS file validation — https://vinejs.dev/docs/types/file5354## Workflow5556### Add an attachment field to a model57581. **Add a converter** in `config/attachment.ts` if the format/size you need doesn't exist yet:59 ```ts60 converters: {61 thumbnail: { options: { resize: 300, format: 'webp' } },62 hero: { options: { resize: 1600, format: 'webp' } },63 },64 ```652. **Decorate the model column**:66 ```ts67 @attachment({ preComputeUrl: false, variants: ['hero'] })68 declare cover: Attachment69 ```703. **Migration**: add a JSON column for the attachment metadata:71 ```ts72 table.json('cover').nullable()73 ```744. **Static `preComputeUrls`** on the model — mirror the pattern used elsewhere:75 ```ts76 static async preComputeUrls(rows: Post | Post[]) {77 if (Array.isArray(rows)) {78 await Promise.all(rows.map((r) => this.preComputeUrls(r)))79 return80 }81 const hero = rows.cover?.getVariant('hero')82 if (hero) await attachmentManager.computeUrl(hero)83 }84 ```855. **Validator**: `cover: vine.file({ extnames: [...], size: 5 * 1024 * 1024 }).nullable()`.866. **Action**: `input.target.cover = await attachmentManager.createFromFile(input.cover)`.877. **Transformer**: expose the URL via a helper (`coverUrl()`) so consumers don't dig into the variant graph.888. **Controller**: call `Model.preComputeUrls(...)` on the loaded rows before running the Transformer.8990### Change a converter9192Edit `config/attachment.ts`. Existing uploads keep their old variant unless you re-process — there's no automatic re-render on config change.9394### Testing9596- Use a real file fixture (small PNG) with the API client's `.file(...)` method.97- Fake Drive: `drive.fake()` in group setup — see [[testing]].98- Assert the DB row has the attachment JSON populated; assert file existence via `disk.assertExists(...)`.99100## Anti-patterns101102- ❌ Skipping `preComputeUrls` before serialization — the Transformer returns `undefined` for the variant URL and the frontend breaks.103- ❌ Setting `preComputeUrl: true` on the decorator to "save code" — every model read now does filesystem I/O, even when the row isn't serialized.104- ❌ Reading `user.avatar.url` directly in a React component — always through a Transformer helper. Otherwise the shape drifts.105- ❌ Storing the raw uploaded filename or path outside `@attachment` — the package owns storage and filenames.106- ❌ Validator without a `size` limit — DoS via huge uploads.107- ❌ Bypassing `attachmentManager.createFromFile(...)` and stuffing the `MultipartFile` into the model field directly.108109## Related skills110111[[crud]] · [[actions-events]] · [[testing]] · [[inertia]]