Latch’s Avram integration lets you attach a file to a model column with a single macro on the model and a single macro on the save operation. From there, the upload lifecycle (caching the file before save, promoting it after commit, replacing the previous file on update, and removing it on delete) happens automatically as part of any normal Avram operation.
This guide assumes you’ve already
set up Latch and defined an
uploader. The running example
is User#avatar, but the same pattern applies to any attachment.
Include Latch::Avram::Model in your model and use attach inside the
table block. The column type is the uploader’s StoredFile, optionally
nilable:
# src/models/user.cr
class User < BaseModel
include Latch::Avram::Model
table do
attach avatar : AvatarUploader::StoredFile?
end
end
Drop the ? to make the attachment required. A non-nilable attachment is
enforced at the database level (JSON::Any instead of JSON::Any?) and
on the save operation, where a missing file causes the save to fail:
table do
attach avatar : AvatarUploader::StoredFile
end
Required attachments don’t get a delete_<column> virtual attribute,
since clearing them on their own would put the record in an invalid
state. Replace the file by uploading a new one instead.
Behind the scenes, attach declares a jsonb column that stores the
serialised StoredFile. Your migration adds a single JSON::Any? column
(or JSON::Any if the attachment is required):
# db/migrations/20260101000001_add_avatar_to_users.cr
class AddAvatarToUsers::V20260101000001 < Avram::Migrator::Migration::V1
def migrate
alter table_for(User) do
add avatar : JSON::Any?
end
end
def rollback
alter table_for(User) do
remove :avatar
end
end
end
On the save operation side, attach registers a file attribute and the
lifecycle hooks that move the file through cache and store:
# src/operations/save_user.cr
class SaveUser < User::SaveOperation
permit_columns name, email
attach avatar
end
By default the file attribute is named <column>_file, so avatar_file
in this case. Override it with field_name if you need a different param
name (for example, to match an existing form):
attach avatar, field_name: "avatar_upload"
For nilable attachments, Latch also adds a virtual delete_<column>
attribute, so users can clear an avatar without uploading a new one:
SaveUser.update!(user, delete_avatar: true)
Validations live in a before_save block, just like every other Avram
validation. Latch ships two helpers (validate_file_size_of and
validate_file_mime_type_of) that operate on the virtual file attribute:
class SaveUser < User::SaveOperation
permit_columns name, email
attach avatar
before_save do
validate_file_size_of avatar_file, max: 5_000_000
validate_file_mime_type_of avatar_file,
in: %w[image/png image/jpeg image/webp]
end
end
validate_file_mime_type_of also accepts a regular expression with
with:, which is handy for “any image” or “any video”:
validate_file_mime_type_of avatar_file, with: /\Aimage\/.+\z/
Validation failures attach to the file attribute and surface in the form just like any other field error.
To process the file inline as part of the save, pass process: true:
attach avatar, process: true
Variants are then ready to read on the returned record:
user = SaveUser.create!(params)
user.avatar.try(&.versions_thumb.url)
Inline processing is fine for small images, but anything heavier (high resolution photos, videos, PDFs with hundreds of pages) should run in a background job so the user isn’t kept waiting on the response.
Pass a block to attach instead of process: true. The block runs after
the file has been promoted, with the saved record yielded in. Enqueue
your background job from there:
class SaveUser < User::SaveOperation
permit_columns name, email
attach avatar do |user|
User::AvatarProcessingJob.run(user_id: user.id)
end
end
Mel is a background job library for
Crystal. A processing job is a struct that includes Mel::Job::Now (or
one of the scheduled variants):
# src/jobs/user/avatar_processing_job.cr
struct User::AvatarProcessingJob
include Mel::Job::Now
def initialize(@user_id : Int64)
end
def run
user = UserQuery.find(@user_id)
user.avatar.try(&.process)
end
end
Mosquito works the same way.
Define a queued job that takes the user ID as a parameter and calls
process inside perform:
# src/jobs/user/avatar_processing_job.cr
class User::AvatarProcessingJob < Mosquito::QueuedJob
param user_id : Int64
def perform
user = UserQuery.find(user_id)
user.avatar.try(&.process)
end
end
Then enqueue from the operation block:
attach avatar do |user|
User::AvatarProcessingJob.new(user_id: user.id).enqueue
end
Anything that can call a method asynchronously will work. Latch makes no assumptions about your job library.
For a complete picture of what attach actually does, here’s what
happens to a file as it moves through a save operation:
process: true is set, processors run inline.
Otherwise the block (if any) is invoked with the saved record.The form side is plain Lucky. Use multipart: true so the browser sends
the file as multipart/form-data, and file_input for the actual upload
field. Latch’s file attribute (avatar_file) plugs into the existing
Shared::Field component without any extra wiring:
# src/pages/users/new_page.cr
class Users::NewPage < MainLayout
needs op : SaveUser
def content
form_for Users::Create, multipart: true do
mount Shared::Field, op.name
mount Shared::Field, op.email
mount Shared::Field, op.avatar_file, &.file_input
submit "Save"
end
end
end
For a nilable attachment with the delete_avatar checkbox:
if op.record.try(&.avatar)
mount Shared::Field, op.delete_avatar, &.checkbox
end
Actions need no special handling. File attributes are part of params,
and Latch picks them up automatically inside attach:
# src/actions/users/create.cr
class Users::Create < BrowserAction
post "/users" do
SaveUser.create(params) do |op, user|
if user
redirect to: Users::Show.with(user.id)
else
html NewPage, op: op
end
end
end
end
On the read side, a stored file knows its own URL, and any variant accessors generated by your processor work the same way. The most common pattern is a small helper on the page that handles the nilable case:
# src/pages/users/show_page.cr
class Users::ShowPage < MainLayout
needs user : User
def content
img src: avatar_url, alt: user.name
end
private def avatar_url : String
if avatar = user.avatar.try(&.versions_thumb?)
avatar.url
else
asset("images/avatar_placeholder.png")
end
end
end
The nilable versions_thumb? accessor returns nil if the variant
hasn’t been processed yet, which is useful when uploads are processed in
the background and you need to render something sensible in the meantime.
For files in private storage, swap avatar.url for a presigned URL:
avatar.url(expires_in: 1.hour)
That’s the end of the file uploads tour. For everything that wasn’t covered here (alternative storage backends, the full processor option reference, custom extractors, the full StoredFile API), see the Latch README and the Latch API docs.