Skip to content

Model Contracts ​

This page focuses on the user-visible contracts exposed by the Model package.

Attribute contracts ​

fill() is strictly allowlisted ​

Model::fill() only accepts keys present in $fillable.

If any key is not allowed, the package throws ModelException::inappropriateProperty(...) and stops filling.

There is no silent ignore behavior.

Direct assignment is not guarded ​

These write paths are different:

  • fill(['title' => 'Hello']) checks $fillable
  • $model->title = 'Hello' does not
  • $model->prop('title', 'Hello') does not

Use fill() when you want mass-assignment protection.

Hidden fields affect export, not storage ​

$hidden only changes asArray() output.

Hidden keys are still present in $attributes, still readable from the model object, and still available to persistence logic.

isEmpty() checks visible exported data ​

isEmpty() delegates to asArray().

A model with only hidden attributes can therefore look empty from this API.

Database model contracts ​

ORM-backed operations expect a factory-created model instance ​

DB-backed methods expect an attached ORM instance. When a model is created outside the package factory path, those methods raise ModelException::ormIsNotSet().

In normal application code, use model(Post::class) instead of new Post().

Primary key stays outside fill() input ​

DbModel::shouldFill() always rejects the key that matches $idColumn, even if you put that key in $fillable.

Primary key values may still appear later when they come from hydration or after save() syncs the generated ID back from the ORM.

Query-builder methods are proxied, not native model methods ​

Methods like these are forwarded to the DBAL adapter:

  • select()
  • criteria() and criterias()
  • having()
  • groupBy()
  • orderBy()
  • offset()
  • limit()
  • joinTo()
  • isNull() and isNotNull()

If the active adapter does not implement a called method, DbModel throws ModelException::methodNotSupported(...).

Retrieval methods return fresh model objects ​

  • findOne() returns one hydrated model or null
  • findOneBy() returns one hydrated model or null
  • first() returns one hydrated model or null
  • get() returns ModelCollection

These methods do not return the same builder instance you queried on.

create() resets attributes first ​

DbModel::create() clears the current model attributes and then calls create() on the ORM.

Treat it as the start of a new-record workflow, not as a way to preserve staged attributes.

save() timestamp hook is opt-in by method presence ​

If the model has a touchTimestamps() method, save() calls it before syncing attributes into the ORM.

This is how HasTimestamps plugs in.

HasTimestamps preserves created_at on first save and refreshes updated_at on every save ​

On a new record, HasTimestamps writes the created-at column only when that attribute is not already present.

That lets you seed a custom creation timestamp before the first save().

The updated-at column is refreshed on every save(), including the first insert.

save() never writes the current primary key value back into the ORM payload ​

During sync, the field matching $idColumn is skipped.

After a successful save, the package reads the ID from the ORM and stores it back in model attributes when present.

Relation contracts ​

relations() must return an array where each key is the fully qualified related model class.

Required relation keys ​

Each relation used by joinTo() must provide:

  • type
  • foreign_key
  • local_key

Missing definitions raise dedicated ModelException variants.

Supported join relation types are narrower than the enum ​

Relation::BELONGS_TO_MANY exists as a constant, but current join handling supports these relation types:

  • Relation::HAS_ONE
  • Relation::HAS_MANY
  • Relation::BELONGS_TO

For join queries, keep relation definitions within that set.

Collection contracts ​

Collections contain only model instances ​

ModelCollection validates every item. Non-model values raise ModelException::notInstanceOf(...).

Collection mutation is in-memory only ​

add() and remove() change the collection contents only. They do not save or delete anything in the database.

Trait contracts ​

Timestamp columns are configurable ​

HasTimestamps reads from constants first and public properties second:

  • CREATED_AT or $createdAt
  • UPDATED_AT or $updatedAt
  • TIMESTAMP_TYPE or $timestampType

Supported timestamp types are effectively:

  • datetime -> Y-m-d H:i:s
  • unix -> time() integer

Any other value falls back to the datetime branch.

Soft deletes are query-scope based ​

Without withTrashed(), soft-delete reads automatically add a deleted_at IS NULL style filter.

onlyTrashed() switches the model into trashed mode and also adds a deleted_at IS NOT NULL filter.

Reused soft-delete builders keep accumulating scope filters ​

Each read method applies the soft-delete scope to the current ORM query before running.

A fresh model instance gives each query a clean scope. Reusing one long-lived builder can stack multiple soft-delete predicates onto the same query state.

withTrashed() and onlyTrashed() mutate the current model instance ​

The include-trashed flag is stored on the model object. It is not automatically reset after one query.