Models & ORM
Models manage data and business logic in your MVC application. SoliLang provides a simple OOP-style interface for database operations.
Defining Models
Create model files in app/models/. The collection name is automatically derived from the class name:
User→"users"BlogPost→"blog_posts"UserProfile→"user_profiles"
Automatic Collection Creation: When you call a Model method (like create(), all(), find(), etc.)
on a collection that doesn't exist yet, SoliLang will automatically create the collection for you. This means you can start using your models immediately without running migrations first.
class User < Model end
That's it! No need to manually specify collection names or field definitions.
Auto-Loading
Every .sl file under app/models/ is loaded automatically at startup — by soli serve (in each worker) and by the REPL. Model classes are therefore available everywhere (controllers, views, other models, the REPL) without an import statement.
class UsersController < Controller
fn index
render("users/index", { "users": User.all })
end
end
If you run a file directly with soli run path/to/file.sl, the auto-loader does not run — in that case you still need explicit imports. The linter flags redundant controller-side imports via style/redundant-model-import.
CRUD Operations
Auto-creation: All Model operations automatically create the collection if it doesn't exist. This only happens on the first call that encounters a missing collection.
Class Instances: All query methods (find, all, where, create, etc.) return proper class instances.
For example, User.find(id) returns a User instance, not a raw hash. This enables instance methods like .save(), .update(), and .delete().
CREATE Creating Records
user = User.create({
"email": "[email protected]",
"name": "Alice",
"age": 30
})
# Returns a User instance. On success, user._errors is nil.
# On validation or DB failure the instance is NOT persisted and
# user._errors is an array of error entries.
user.name # => "Alice"
user._key # => "abc123" (auto-generated)
user._errors # => nil
READ Finding Records
# Find by ID - returns a User instance
user = User.find("user123")
user.name # => "Alice"
# Find all - returns array of User instances
users = User.all
users.first.name # => "Alice"
# Find with filter — Hash form (recommended for user input).
# Each key is validated as an AQL identifier and values flow through
# bind parameters; equality semantics, all pairs joined with AND.
admins = User.where({ "role": "admin", "active": true }).all
alice = User.where({ "email": "[email protected]" }).first
# Find with filter — string form (developer-trusted only). Use this when
# you need operators (>=, IN, etc.). The filter string MUST NOT come
# from untrusted input — see the Security note below.
adults = User.where("age >= @age", { "age": 18 }).all
results = User.where("age >= @min AND role == @role", {
"min": 21,
"role": "admin"
}).all
# Dynamic finder methods — automatically generated
user = User.find_by_email("[email protected]")
user = User.find_by_email_and_active("[email protected]", true)
Security — where(...) filter forms.
The Hash form (where({field: value, ...})) is safe for user input: keys are validated as [A-Za-z_][A-Za-z0-9_]* identifiers and values are bound, so nothing from req["params"] can become AQL syntax. The string form (where("doc.foo == @foo", {...})) splices the filter argument verbatim into the AQL FILTER clause — treat it as developer-trusted only, like a format!() template. Building a filter string from request data will leak full AQL injection. When the operators you need go beyond equality, prefer composing small string-form clauses around literal strings rather than concatenating user input into them.
UPDATE Updating Records
# Static method: update by ID
User.update("user123", { "name": "Alice Smith", "age": 31 })
# Instance method: modify fields and save
user = User.find("user123")
user.name = "Alice Smith"
user.save()
# Or use .update() to push current fields to DB
user.name = "Alice Smith"
user.update()
# Instance method with bulk-update hash — merge-then-persist in one call
user.save({ "name": "Alice Smith", "age": 31 })
# `.update(hash)` is equivalent on an existing record
user.update({ "name": "Alice Smith", "age": 31 })
DELETE Deleting Records
# Static method
User.delete("user123")
# Instance method
user = User.find("user123")
user.delete()
COUNT Counting Records
total = User.count
Instance Methods
All model instances have built-in methods for persistence:
| Method | Description |
|---|---|
instance.save(hash?) |
Insert (if new) or update (if existing). Optional hash is merged onto the instance before validation & persist. Returns the instance with _key populated. |
instance.update(hash?) |
Persist current non-_ fields to DB. Optional hash is merged first. Requires _key. |
instance.delete() |
Delete the document from DB. Requires _key. Supports soft delete. |
instance.reload() |
Refresh instance from database. |
instance.increment(field, n?) |
Atomically add n (default 1) to a numeric field. Uses an If-Match CAS loop on _rev with bounded retry — concurrent increments cannot lose updates. |
instance.decrement(field, n?) |
Atomically subtract n (default 1) from a numeric field. Same CAS-on-_rev retry as increment. |
instance.touch() |
Update _updated_at timestamp without changing other fields. |
instance.restore() |
Restore a soft-deleted record (clear deleted_at). |
instance.errors |
Return validation errors from last save/update. |
# Create a new instance and save it
user = User.new()
user.name = "Bob"
user.email = "[email protected]"
user.save()
# user._key is now set
# Modify and save again
user.name = "Robert"
user.save()
# Delete
user.delete()
Atomic increment / decrement
increment and decrement are not plain read-modify-writes on the in-memory instance — each call drives an optimistic compare-and-swap loop against SoliDB:
- Re-fetch the document to read the current field value and its
_rev. - Compute
current + delta(orcurrent - delta). - PUT the new value with an
If-Match: <rev>header. - If another writer modified the document in between, the DB returns
409 Conflictand the loop retries (up to 10 attempts) by re-fetching.
On success the in-memory instance's field and _rev are refreshed, so any follow-up call observes the same state the DB now holds. Concurrent increments cannot lose updates: every successful PUT was the unique continuation of the rev it read.
Under extreme contention all 10 retries can fail; the call returns an error like "increment failed: Atomic update of users.view_count failed after 10 attempts (too much contention)" instead of silently dropping the update. Callers can retry, queue the work, or back off as they prefer.
Bulk attribute updates
Both .save() and .update() accept an optional hash of attributes that get merged onto the instance before validations and the DB write run. One call replaces N assignments + save:
# Instead of:
user.name = "Alice"
user.email = "[email protected]"
user.role = "admin"
user.save()
# Write:
user.save({
"name": "Alice",
"email": "[email protected]",
"role": "admin"
})
Merge semantics: keys you don't pass keep their current instance value; keys you do pass overwrite. Mix it with direct assignment too — hash wins on conflict:
# Partial update
p = Product.find(id)
p.update({ "price": 99.00 }) # only price changes
# Mix pre-assignment + hash
p = Product.new()
p.name = "Widget" # will survive
p.save({ "price": 12.50 }) # name stays "Widget", price becomes 12.50
Read-only fields: Fields starting with _ (_key, _id, _rev, etc.) are read-only on model instances. They're set automatically by the database and cannot be assigned directly — and are silently skipped when included in a bulk-update hash.
A non-hash argument raises expected a Hash of attributes, got <type>. Validations run after the merge, so errors surface on instance.errors identically to the assignment-then-save pattern.
Static Methods Reference
| Method | Description |
|---|---|
Model.create(data) |
Insert a new document. Returns a class instance; on failure instance._errors is an array and the record is not persisted, on success instance._errors is nil. |
Model.create_many([data, ...]) |
Batch insert multiple documents, returns { "created": n } |
Model.find(id) |
Raises RecordNotFound when the id is missing (auto-mapped to a 404 HTTP response). Use find_by for optional lookups. |
Model.find_by(field, value) |
Find first record by field value, returns null when missing. |
Model.first_by(field, value) |
Find first record by field with ordering |
Model.find_or_create_by(field, value, data?) |
Find by field, or create if not found |
Model.find_by_* |
Dynamic finder: find_by_field, find_by_field1_and_field2(...), etc. |
Model.where(hash) |
Hash filter — safe for user input (keys validated, values bound) |
Model.where(string, bind_vars) |
SDBQL filter string — developer-trusted only (do not pass user input) |
Model.order(field, direction) |
Start a query chain sorted by field ("asc"/"desc") |
Model.limit(n) |
Start a query chain limited to n results |
Model.offset(n) |
Start a query chain with offset |
Model.paginate(hash) |
Terminal: fetch paginated results + metadata. Args: page (default 1), per (default 25) |
Model.all |
Get all documents as instances |
Model.count |
Count all documents |
Model.update(id, data) |
Update a document |
Model.upsert(id, data) |
Insert or update document by ID |
Model.delete(id) |
Delete a document |
Model.delete_all |
Wipe every document in the collection (primarily for test setup/teardown). For filtered bulk deletes use Model.where(...).delete_all. |
Model.transaction() |
Get transaction handle for manual control |
Model.scope(name) |
Execute a named scope (returns QueryBuilder) |
Model.with_deleted |
Include soft-deleted records (QueryBuilder) |
Model.only_deleted |
Query only deleted records (QueryBuilder) |
Model.includes(rel, ...) |
Eager load relations (returns QueryBuilder) |
Model.select(field, ...) |
Select specific fields (returns QueryBuilder) |
Model.join(rel, filter?, binds?) |
Filter by related existence (returns QueryBuilder) |
Model.sum(field) / avg / min / max |
Aggregation (returns QueryBuilder, chain .first to execute) |
Model.group_by(field, func, agg_field) |
Group-by aggregation (returns QueryBuilder, chain .all) |
Model.pluck(field, ...) |
Select fields only (returns QueryBuilder, chain .all) |
Model.mock_query_result(query, results) |
Register mock data for an AQL query (for testing) |
Model.clear_mocks() |
Clear all registered mock responses |
Mass Assignment Protection
By default, Model.create(hash) and instance.update(hash) write every key in the supplied hash straight to the document. If hash came from a request body, that includes any field a client decides to send — role, is_admin, password_digest, etc. Declare attr_accessible(...) on the model to lock down which keys mass-assign accepts.
class User < Model
# Variadic form
attr_accessible("name", "email", "bio")
# …or a single array — equivalent
# attr_accessible(["name", "email", "bio"])
end
User.create({
"name": "Alice",
"email": "[email protected]",
"role": "admin" # silently dropped — not in the whitelist
})
Filtering applies to every mass-assign path: Model.create(hash), Model.update(id, hash), instance.update(hash), instance.save(hash). Non-permitted keys are dropped before validation runs and before the document is written, so they cannot be probed via validation errors either.
Empty list = full lock-down. attr_accessible([]) declares that the model accepts no mass-assigned attributes; everything must be set by trusted server code via direct field assignment (e.g. user.role = "admin").
Models without a declaration keep the legacy "all keys accepted" behaviour for backwards compatibility. New models that take request data should always declare attr_accessible; audit every Model.create/Model.update call site against an explicit whitelist.
For controller-side filtering (when you'd rather hand-pick keys at the boundary), hash.slice(["a", "b"]) returns a new hash with only the listed keys — useful when you need a different whitelist per action:
fn update
let user = User.find(req["params"]["id"])
let safe = req["json"].slice(["name", "bio"])
user.update(safe)
redirect("/users/" + user._key)
end
Pagination
Model.paginate(hash) (static) and .paginate(hash) (chainable on a QueryBuilder) are terminal methods that execute the query with pagination and return a hash with both records and pagination metadata.
Arguments
| Key | Default | Description |
|---|---|---|
page |
1 |
Page number (1-indexed, clamped to valid range) |
per |
25 |
Results per page |
Return Value
{
"records": [...], # Array of model instances for this page
"pagination": {
"page": 1, # Current page (clamped)
"per": 25, # Results per page
"total": 100, # Total matching records (unpaginated)
"total_pages": 4 # Total number of pages
}
}
Usage
Chain from any QueryBuilder — all filters, includes, ordering, etc. are preserved:
def index
let result = Contact
.search(@q)
.includes("organisation")
.order("name", "asc")
.paginate({ "page": page, "per": 25 })
@contacts = result["records"]
@pagination = result["pagination"]
end
The paginate method runs count first to get the total, computes total_pages, clamps page to the valid range, sets offset and limit, fetches records, and returns the result hash. If total is 0, total_pages is set to 1 and page is clamped to 1.
Uploaders
Declare a blob attachment on a model with uploader(name, options). Soli registers the field, validates incoming files against the rules you supply, and stores the blob in SoliDB. The DSL also auto-generates instance methods so the controller is a one-liner.
class Contact < Model
uploader("photo", {
"multiple": false,
"content_types": ["image/jpeg", "image/png", "image/webp"],
"max_size": 2_000_000,
"collection": "contact_photos" # optional, defaults to <snake>_<field>s
})
end
Options
multiple—falsestores one blob in<name>_blob_id;truestores an array of ids in<name>_blob_ids.content_types— allowlist checked before the blob is stored. Anything else fails fast with no SoliDB round-trip.max_size— hard cap in bytes. Same fast-fail.collection— SoliDB collection name. Optional; defaults to<model_snake_case>_<field>s.
Auto-generated instance methods
For each declared uploader, Soli synthesizes:
| Method | Behavior |
|---|---|
| contact.attach_photo(file) | Validate, store, replace the previous blob (single mode) or append (multiple mode). Sets _errors on failure. |
| contact.detach_photo([blob_id]) | Remove the current blob (single) or the named one (multiple). blob_id is required for multiple uploaders. |
| contact.photo_url() | Return the proxy URL or null. Single-mode only. |
| contact.photo_urls() | Return one URL per stored blob_id. Multiple-mode only. |
A typical edit flow becomes a single call:
def update
@contact = Contact.find(params.id)
if @contact.update(this._permit(params))
return @contact.detach_photo() if params["remove_photo"] == "1"
file = find_uploaded_file(req, "photo")
@contact.attach_photo(file) unless file.nil?
# ...
end
end
Multiple attachments
Set multiple: true to keep an array of blob ids on the record. The auto-generated attach_<field>(file) appends; detach_<field>(blob_id) removes a specific blob. detach_all_uploads(record) walks every uploader on destroy.
class Contact < Model
uploader("document", {
"multiple": true,
"content_types": ["application/pdf", "image/jpeg", "image/png",
"application/zip", "text/csv"],
"max_size": 10_000_000,
"collection": "contact_documents"
})
end
For HTML form upload (POST → redirect → flash → reload page), thin the controller to one call per side; _errors carries the framework's validation message:
# POST /contacts/:id/documents
def attach_document
contact = Contact.find(params.id)
file = find_uploaded_file(req, "document")
if file.nil?
flash("error", "Pick a file before submitting.")
elsif contact.attach_document(file)
flash("success", "Document filed.")
else
flash("error", (contact._errors[0] ?? { "message": "Upload failed." })["message"])
end
redirect("/contacts/#{contact._key}")
end
# POST /contacts/:id/document/:blob_id/delete
def detach_document
contact = Contact.find(params.id)
if contact.detach_document(params.blob_id)
flash("success", "Document removed.")
else
flash("error", "Document not found on this record.")
end
redirect("/contacts/#{contact._key}")
end
Prefer uploads("contacts", "document") in routes if you want JSON 204/422 (drag-and-drop with JS); use the manual routes above when an HTML form needs to redirect on success.
Shipped with the framework — no setup needed
The upload helpers (find_uploaded_file, attach_upload, detach_upload, detach_all_uploads, upload_url) and the generic AttachmentsController that handles
auto-routed attachments
are loaded on every soli serve — you do not need to copy any controller file into app/controllers/. Declaring uploader(...) on a model and uploads("resource", "field") in routes is enough.
SoliDB connection details come from environment variables (with sensible defaults):
SOLIDB_HOST— defaulthttp://localhost:6745SOLIDB_DATABASE— defaultdefaultSOLIDB_USERNAME/SOLIDB_PASSWORD— auth skipped if unset
Image transforms via URL
For image attachments, the auto-routed GET /<resource>/:id/<field> endpoint can resize, crop, recompress, and reformat on the fly. Pass options to upload_url(...) (or the auto-generated <field>_url(...) dot-method) and they're rendered into the URL's query string. The AttachmentsController reads the same params from req["query"], runs the bytes through the
Image class, and returns the transformed payload.
| Param | Type | Effect |
|---|---|---|
| w | Int (px, ≤1000) | Width. Alone: thumbnail to that max edge (preserves aspect). With h: behaviour depends on fit. Values above 1000 are silently clamped. |
| h | Int (px, ≤1000) | Height. Pair with w. Clamped to 1000. |
| thumb | Int (px, ≤1000) | Square-fit thumbnail at this max edge. Output is at most N×N preserving aspect — a 800×400 source becomes 200×100 with thumb=200. Wins over w/h if both supplied. Clamped to 1000. |
| square | Int (px, ≤1000) | Sugar for w=N&h=N&fit=cover. Output is exactly N×N, scaled-and-cropped to fill — typical avatar/grid use case. Explicit w/h/fit override the shorthand. Clamped to 1000. |
| crop | String x,y,w,h | Crop a rectangular region from the source before any resize. e.g. crop=10,20,300,200. Accepts an Array [10,20,300,200] in the options hash. The w and h components are clamped to 1000; x/y offsets are unbounded (out-of-bounds is rejected by the Image lib and falls back to the raw blob). |
| fit | String | Pairs with w + h: cover = scale to fill then center-crop overflow (output is exactly w×h); contain = scale to fit inside w×h, preserving aspect. Without fit, w + h is an exact resize that may distort. |
| flipx | Truthy flag | Mirror horizontally (img.flip_horizontal()). |
| flipy | Truthy flag | Mirror vertically (img.flip_vertical()). |
| rot | Int 90/180/270 | Rotate clockwise. Other values are ignored. |
| blur | Float | Gaussian blur sigma — higher = more blur. blur=3.5. |
| bright | Int (±) | Brightness offset (img.brightness(value)). Positive brightens, negative darkens. |
| contrast | Float | Contrast factor (img.contrast(value)). |
| hue | Int degrees | Hue rotation (img.hue_rotate(degrees)). |
| gray | Truthy flag | Convert to grayscale (img.grayscale()). |
| invert | Truthy flag | Invert all colors (img.invert). |
| fmt | String | Output format: jpeg, png, webp, gif, bmp, tiff, ico. Sets the response Content-Type. |
| q | Int 1–100 | JPEG/WebP quality. |
All flags are optional. Without any, the original bytes are streamed unmodified (current behaviour).
DoS guardrail. The dimension params (w, h, thumb, square, and the w/h in crop) are server-side clamped to 1000 px. A crafted ?w=99999 won't make the worker allocate gigabytes — it's silently treated as ?w=1000. Cap is enforced in the framework prelude regardless of what the URL builder generates.
Sizing modes — when to use which. Given a 800×400 source:
w=200alone orthumb=200→ 200×100 (fits within 200×200, aspect preserved, smaller dim shrinks too).w=200&h=200→ 200×200 stretched (squashed, aspect distorted — usually not what you want).w=200&h=200&fit=coverorsquare=200→ 200×200 exact (scale-and-crop, content cropped left/right, aspect preserved within the crop).w=200&h=200&fit=contain→ 200×100 (same asthumb=200here; useful when you want a non-square bounding box).
Reach for square=N for avatars and grid tiles where every cell must be the same size. Use thumb=N (or w=N) when you need to bound the larger edge but keep aspect.
<img src="<%= contact.photo_url({ "thumb": 200 }) %>" alt="...">
# → <img src="/contacts/42/photo?v=abc&thumb=200" alt="...">
contact.photo_url({
"w": 800,
"h": 600,
"fmt": "webp",
"q": 80,
"gray": true
})
# → /contacts/42/photo?v=abc&w=800&h=600&fmt=webp&q=80&gray=1
contact.photo_url({ "square": 200 })
# → /contacts/42/photo?v=abc&square=200
contact.photo_url({ "w": 200, "h": 200, "fit": "cover" })
# → /contacts/42/photo?v=abc&w=200&h=200&fit=cover
# Both render exactly 200×200, content scaled-and-cropped.
contact.photo_url({ "crop": [100, 50, 600, 600], "w": 200 })
# → /contacts/42/photo?v=abc&w=200&crop=100%2C50%2C600%2C600
# (the comma is percent-encoded; the AttachmentsController decodes it.)
contact.photo_url({
"rot": 90,
"blur": 2.5,
"bright": 15,
"contrast": 1.2
})
# → /contacts/42/photo?v=abc&rot=90&blur=2.5&bright=15&contrast=1.2
Pipeline order. Transforms are applied in a fixed sequence so identical params always produce identical output (and identical URLs — cache-key stability for CDNs):
crop— pick the source region first.flipx,flipy,rot— orientation.thumb/fit/w+h— sizing applies to the rotated frame.blur,bright,contrast,hue,gray,invert— effects.fmt,q— encode.
Caching. Each unique combination of params is a different URL — browsers and CDNs cache them as separate entries. The cache buster (?v=<blob_id>) keeps single-mode URLs unique per blob, so replacing the photo invalidates every transformed variant at once. Transformed responses are served with Cache-Control: public, max-age=86400; the raw passthrough uses private, max-age=300.
If a transform fails for any reason (corrupted bytes, unsupported source format, invalid params), the controller silently falls back to streaming the original blob — the page never breaks because of a bad query string.
Overriding the defaults
Define a same-named function or class in app/controllers/ and your version wins — the framework prelude runs before user controllers, so user definitions naturally shadow it.
class AttachmentsController < ApplicationController
static {
this.before_action(:create, :destroy, fn(req) {
return halt(403) unless req["current_user"].is_admin()
})
}
# ... or override individual actions
end
Same trick for solidb_client(): define your own in app/controllers/support.sl and the prelude's default is bypassed.
Cleanup on destroy
before_delete callbacks aren't yet dispatched by Model.delete(id), so call detach_all_uploads(record) explicitly before deleting until that lands. The helper iterates every uploader declared on the class.
def destroy
contact = Contact.find(params.id)
detach_all_uploads(contact) unless contact.nil?
Contact.delete(params.id)
redirect("/contacts")
end
Reflection helpers
Inside controllers or generic helpers you can read a model's uploader configuration without hardcoding the rules:
model_uploader_config(class_or_name, field)→ hash of{ name, multiple, content_types, max_size, collection }, ornull.model_uploader_fields(class_or_name)→ list of declared field names.find_model_class_by_collection(collection)→ the model class whoseclass_name_to_collectionmatches, ornull.
See Auto-routed attachments for the matching uploads(resource, field) route helper, which mounts show/create/destroy endpoints driven by the same config.
Complete Example
# app/models/user.sl
class User < Model
has_many("posts")
has_one("profile")
validates("email", { "presence": true, "uniqueness": true })
validates("name", { "presence": true, "min_length": 2 })
before_save("normalize_email")
def normalize_email
this.email = this.email.downcase
end
def is_adult -> Bool
this.age >= 18
end
end
# app/models/post.sl
class Post < Model
belongs_to("user")
has_many("comments")
validates("title", { "presence": true, "min_length": 3 })
end
# Usage in controller
class UsersController < Controller
def index(req: Any)
# Eager load posts and profiles to avoid N+1 queries
users = User.includes("posts", "profile").all
render("users/index", { "users": users })
end
def show(req: Any)
id = req["params"]["id"]
user = User.includes("posts").find(id)
render("users/show", { "user": user })
end
def active(req: Any)
# Find active users who have at least one post
users = User.join("posts")
.where("active = @a", { "a": true })
.order("created_at", "desc")
.limit(10)
.all
render("users/active", { "users": users })
end
def create(req: Any)
user = User.create({
"name": req["params"]["name"],
"email": req["params"]["email"]
})
if user._errors
render("users/new", { "errors": user._errors })
else
return redirect("/users/" + user._key)
end
end
def update(req: Any)
user = User.find(req["params"]["id"])
user.name = req["params"]["name"]
user.save()
redirect("/users/" + user._key)
end
def destroy(req: Any)
user = User.find(req["params"]["id"])
user.delete()
redirect("/users")
end
end
Inspecting AQL Queries (Dev Tool)
When the server runs with --dev, every AQL query a request executes through the Model layer is captured into a per-request stack. Read it from any controller or view with the dev_queries() builtin to render a debug bar.
fn index
users = User.where("doc.active == true").all
render("users/index", { "users": users, "queries": dev_queries() })
end
# dev_queries() returns Array<Hash> with keys: query, bind_vars, duration_ms.
# In production it always returns [] with zero overhead.
See the AQL Query Log section in the Debugging guide for the full schema, debug-bar partial example, and coverage notes.
Best Practices
- Keep models simple - Just extend
Model, no configuration needed - Use meaningful class names - They become collection names automatically
- Add validations - Validate data before it reaches the database
- Use callbacks wisely - Keep them focused and avoid heavy operations
- Add custom methods - Encapsulate business logic in model methods
- Declare relationships - Use
has_many,has_one,belongs_to,has_and_belongs_to_manyfor associations - Use
includesfor eager loading - Avoid N+1 queries when accessing related data - Use
joinfor filtering - When you only need to filter by existence, not preload
Explore Further
Query Builder →
Chain methods to build complex queries with filters, ordering, limits, and more.
Relationships →
has_many, has_one, belongs_to, has_and_belongs_to_many, eager loading with includes, and join filtering.
Validations & Callbacks →
Validate data before saving and run lifecycle callbacks.
Finders & Aggregations →
find_by, pluck, exists, sum, avg, min, max, and group_by.
Advanced Features →
Soft delete, scopes, batch operations, transactions, and raw queries.
Migrations →
Create collections and indexes with database migrations.