Scopes & Soft Deletes
Scopes are reusable query conditions. Local scopes are applied when you call them; global scopes are added to every query of a model. Soft deletes are a built-in global scope.
Local Scopes
A method named scope{Name} becomes a chainable {name}() call:
class User extends Model
{
public function scopeActive($query)
{
$query->where('IsActive', true);
}
public function scopeRole($query, string $role)
{
$query->where('Role', $role);
}
}
$users = User::active()->get();
$admins = User::active()->role('admin')->orderBy('Name')->get();
$count = User::where('Country', 'TR')->active()->count();
Global Scopes
A global scope is added to every query of the model: get(), first(), count(), paginate(), the async methods and mass update() / delete(). Each scope is compiled as its own parenthesised group, so orWhere() in your query can never bypass it.
class Document extends Model
{
protected static function boot(): void
{
static::addGlobalScope('tenant', function ($query) {
$query->where('TenantId', CurrentTenant::id());
});
static::addGlobalScope('published', fn($query) => $query->whereNotNull('PublishedAt'));
}
}
Document::where('Title', 'like', 'A%')->orWhere('Pinned', 1)->get();
// WHERE (TenantId = ?) AND (PublishedAt IS NOT NULL) AND (Title LIKE ? OR Pinned = ?)
Document::withoutGlobalScope('published')->get(); // drop one scope
Document::query()->withoutGlobalScopes(['tenant'])->get(); // drop several
Document::query()->withoutGlobalScopes()->get(); // drop all
Document::removeGlobalScope('published'); // remove for the rest of the request
Document::getGlobalScopes(); // ['tenant' => Closure, 'published' => Closure]
$model->fresh() and refresh() ignore global scopes, so they always find the row again.
Soft Deletes
With the SoftDeletes trait, delete() sets DeletedAt instead of removing the row, and every query hides deleted rows.
use Miko\Database\ORM\Traits\SoftDeletes;
class Post extends Model
{
use SoftDeletes; // column DeletedAt; rename: const DELETED_AT = 'RemovedAt';
protected static function defineSchema(TableBuilder $table): void
{
$table->id();
$table->string('Title');
$table->softDeletes(); // nullable DeletedAt column
}
}
| Call | Effect |
|---|---|
$post->delete() | UPDATE ... SET DeletedAt = now (also UpdatedDate with timestamps) |
$post->trashed() | true when DeletedAt is set |
$post->restore() | DeletedAt = NULL |
$post->forceDelete() | real DELETE |
Post::find($id), Post::all(), Post::where(...) | deleted rows are hidden |
Post::withTrashed() | include deleted rows |
Post::onlyTrashed() | only deleted rows |
Post::where(...)->delete() | soft deletes the matching rows |
Post::where(...)->forceDelete() | deletes them for real |
Post::onlyTrashed()->where(...)->restore() | restores them |
BulkOperations::delete(Post::class, $ids) | soft deletes by id |
$post = Post::find(5);
$post->delete();
Post::find(5); // null
$post = Post::withTrashed()->find(5); // found
$post->trashed(); // true
$post->restore();
$trash = Post::onlyTrashed()->latest('DeletedAt')->get();
Post::onlyTrashed()->where('DeletedAt', '<', date('Y-m-d', strtotime('-30 days')))->forceDelete();
Events
Soft deletes fire deleting / deleted; restore() fires restoring / restored; forceDelete() fires forceDeleting / forceDeleted around the normal delete events. Return false from a "before" listener to cancel. In a listener, $model->isForceDeleting() tells a real delete apart.
Unique columns and soft deletes
A soft-deleted row still holds its unique values (for example an e-mail). Either include DeletedAt in the unique index or restore the old row instead of inserting a new one.