Skip to main content
Objects support full-text search via Laravel Scout. Add the Searchable trait and implement toSearchableArray() to define what gets indexed. Objects that implement DomainObjectContract can also opt into the application-wide global search bar.

Basic Setup

use Laravel\Scout\Attributes\SearchUsingPrefix;
use Laravel\Scout\Searchable;
use Inly\Core\Models\Traits\HasDomainObject;

class Company extends Model implements DomainObjectContract
{
    use HasDomainObject, Searchable;

    #[SearchUsingPrefix(['name', 'org_number'])]
    public function toSearchableArray(): array
    {
        return [
            'name' => $this->name,
            'org_number' => $this->org_number,
        ];
    }

    public static function allowGlobalSearch(): bool
    {
        return user()->can(Permission::VIEW_COMPANIES->value);
    }
}
allowGlobalSearch() controls whether the model appears in the application search bar. Gate it behind a permission so users only see objects they can access. Omit the method (or return false) to keep the model out of global search entirely. The #[SearchUsingPrefix] attribute enables prefix matching — “Acm” matches “Acme Corp” but not “Company Acme”.
With the database Scout driver, no indexing happens automatically. Only include actual database columns in toSearchableArray(), not computed values unless they exist as real columns (like computed_text_id).

Searching Joined Columns

Override loadDomainObject() when you need joined data for both search and display:
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Inly\Core\Models\Traits\HasDomainObject;
use Laravel\Scout\Attributes\SearchUsingPrefix;
use Laravel\Scout\Searchable;

class Account extends Model
{
    use HasDomainObject, Searchable;

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    #[SearchUsingPrefix(['computed_text_id'])]
    public function toSearchableArray(): array
    {
        return [
            'computed_text_id' => $this->computed_text_id,
            'users.name' => $this->user?->name,
            'users.ssn' => $this->user?->ssn,
        ];
    }

    #[Scope]
    public function loadDomainObject(Builder $query): Builder
    {
        return $query->with(['campaign'])
            ->joinRelationship('user')
            ->select(
                'accounts.*',
                'users.name',
                'users.ssn',
            );
    }
}
Use table-prefixed keys (e.g., 'users.name') in toSearchableArray() when referencing joined columns.

Search Behavior

By default, all fields in toSearchableArray() use WHERE LIKE %search% OR ... (contains matching). You can configure more specific behavior per field. Use #[SearchUsingPrefix] for fields that should match from the start — “123” matches “12345” but not “51234”:
#[SearchUsingPrefix(['computed_text_id', 'order_number'])]
public function toSearchableArray(): array
{
    return [
        'computed_text_id' => $this->computed_text_id,
        'order_number' => $this->order_number,
        'customer_name' => $this->customer_name,
    ];
}
Numeric IDs cannot use prefix search. Create a computed text column to enable prefix searching on IDs.
For better performance on long text columns (descriptions, articles, comments), add a full-text index:
// Migration
$table->fullText('description');
$table->fullText(['title', 'description']); // Multi-column index
Don’t use full-text indexes on short single-value fields like names, emails, or SKUs — they won’t match well. Full-text is for longer content where tokenization helps.
// Migration
$table->computedTextColumn('id'); // Creates computed_text_id

// Model
#[SearchUsingPrefix(['computed_text_id'])]
public function toSearchableArray(): array
{
    return [
        'computed_text_id' => $this->computed_text_id,
    ];
}

Power Joins

joinRelationship() comes from the eloquent-power-joins package.
// Basic join
$query->joinRelationship('user')

// Nested relationships
$query->joinRelationship('account.user')

// Multiple relationships
$query->joinRelationship('user')->joinRelationship('campaign')
Use leftJoinRelationship() when the relationship might not exist.

Activity Log

The activity log provides an audit trail for domain objects and supports inline comments. It is powered by Spatie activitylog and surfaced in the AppPage sidebar and the system activity log index.

Enable on a Page

use Inly\Core\Pages\AppPage;

return AppPage::make('orders/show')
    ->title($order->getObjectTitle())
    ->activityLog($order);
Disable comments when needed:
return AppPage::make('orders/show')
    ->title($order->getObjectTitle())
    ->activityLog($order, disableComments: true);

Automatic Logging

Models that implement DomainObjectContract and use the HasDomainObject trait get automatic activity logging with these defaults:
  • Logs all attributes.
  • Excludes created_at, updated_at, deleted_at, and remember_token.
  • Only logs dirty (changed) attributes.
  • Skips empty logs.

Attribute Labels

The activity log resolves attribute labels in the following order:
  1. Model-specific override via formatActivityLogAttributeLabel() — for labels specific to a model.
  2. Global attributes from lang/{locale}/validation.php — for common attributes used across multiple models.
  3. Auto-generated from the attribute key — as a final fallback.
Define common labels in lang/{locale}/validation.php so they stay consistent between validation messages and the activity log:
'attributes' => [
    'email' => 'email address',
    'phone' => 'phone number',
    'company_number' => 'organization number',
],
For labels specific to a model’s context, override formatActivityLogAttributeLabel():
public function formatActivityLogAttributeLabel(string $key): ?string
{
    return match ($key) {
        'user_id' => __('Assigned to'),
        'business_central_environment' => __('Business Central environment'),
        default => null,
    };
}

Schema property formatting

When a model has a schema, each property controls how its value is formatted in the activity log via toActivityLogValue():
  • Default (string, number, date, enum, etc.): the value is formatted using the property’s toData() output (e.g. enum label, ISO date).
  • ObjectProperty: the value (a related model or id) is formatted as a linked object reference so the log can resolve and display the related record.
  • RichTextProperty: the value is formatted as rich text so the log shows a “Show difference” modal instead of raw HTML.
You only need to override formatActivityLogAttributeValue() when you want formatting that is not covered by the schema (e.g. custom labels for enum values or non-schema attributes).

Custom Values

Override formatActivityLogAttributeValue() for custom value formatting when the schema does not apply or you need different behaviour:
use Inly\Core\Support\ActivityLog\ActivityValueFormatter;

public function formatActivityLogAttributeValue(string $key, mixed $value): mixed
{
    return match ($key) {
        'status' => ActivityValueFormatter::enum(OrderStatus::class, $value),
        'customer_id' => ActivityValueFormatter::objectReference(Customer::class, $value),
        'external_id' => ActivityValueFormatter::objectReference(Customer::class, $value, 'external_id'),
        'related_order_ids' => ActivityValueFormatter::objectReference(Order::class, $value),
        'locale' => ['sv' => __('Swedish'), 'en' => __('English')][$value] ?? null,
        default => null,
    };
}

Custom Descriptions

Override formatActivityLogDescription() to customize how activity entries are described:
use Spatie\Activitylog\Models\Activity;

public function formatActivityLogDescription(Activity $activity): ?string
{
    return match ($activity->description) {
        'updated' => __('Updated profile'),
        default => null,
    };
}

Rich Text Content

Properties containing HTML rich text should use ActivityValueFormatter::richText() so the activity log renders them properly instead of showing raw HTML:
public function formatActivityLogAttributeValue(string $key, mixed $value): mixed
{
    return match ($key) {
        'description' => ActivityValueFormatter::richText($value),
        default => null,
    };
}
Instead of an inline before/after diff (which would show unreadable HTML), the activity log renders a “Show difference” button. Clicking it opens a modal that displays the old and new HTML content side by side, rendered with Tailwind prose styles.

ActivityValueFormatter Reference

MethodDescription
objectReference($modelClass, $value, $column = 'id')Linked object reference. Supports single values and arrays.
enum($enumClass, $value)Formats enums using label() when available.
date($value) / datetime($value)Formats dates.
richText($value)Formats HTML rich text for modal diff view.
auto($model, $key, $value)Auto-formats based on model casts.

Comments

When the activity log is enabled on an AppPage, a comment form is displayed above the feed unless comments are disabled.
  • Comments are stored as activity entries with log_name = comments and properties.message.
  • The comment endpoint uses a signed URL.
  • Users can delete their own comments.

State history

State history records when a model’s stateful attributes (e.g. status, phase) change, who caused the change, and how long each state lasted. Use it for audit trails, time-in-state reporting, and “path” queries (e.g. “orders that went through draft|pending|approved”).

When to use it

  • Status or workflow fields — track status, phase, stage, or any enum/string that represents a step in a process.
  • Audit trail — see who moved an order from “pending” to “approved” and when.
  • Duration and path — each entry stores duration (milliseconds in that state) and a path (e.g. draft|pending|approved) so you can report on time spent per state or filter by path.

Enable tracking on a model

Use the TracksStateHistory trait and declare which attributes to track:
use Inly\Core\Models\Traits\TracksStateHistory;

class Order extends Model
{
    use TracksStateHistory;

    public function trackedStateAttributes(): array
    {
        return ['status', 'phase'];
    }
}
Transitions are recorded automatically on created and updated. Enum and Carbon values are normalized to strings; the authenticated user is stored as the causer unless you pass one explicitly.

Reading state history

What you needHow to get it
All state history for the model$order->stateHistories()
History for one attribute, chronological$order->stateHistoryFor('status')
Current path string (e.g. draft|pending)$order->currentStatePath('status')
Current path as array of steps$order->currentStatePathSteps('status')
Each StateHistory entry has: old_state, new_state, path, duration (ms), is_latest, causer (morph to user/model), and timestamps.

Querying StateHistory

Use the StateHistory model when you need to query across many records:
use Inly\Core\Models\StateHistory;

// All entries for one model + attribute
StateHistory::query()
    ->forTrackable($order)
    ->forAttribute('status')
    ->oldest()
    ->get();

// Only the current (latest) entry per trackable + attribute
StateHistory::query()
    ->forTrackable($order)
    ->forAttribute('status')
    ->isLatest()
    ->first();

// Entries whose path starts with a prefix (e.g. draft|pending or draft|pending|approved)
StateHistory::query()
    ->forTrackable($order)
    ->forAttribute('status')
    ->withPathPrefix('draft|pending')
    ->get();

// Entries that passed through a given state at any point
StateHistory::query()
    ->forTrackable($order)
    ->forAttribute('status')
    ->throughState('approved')
    ->get();

Manual recording

When you change state outside normal Eloquent create/update (e.g. in a job or console command), record the transition yourself:
$order->recordStateTransition('status', OrderStatus::APPROVED);

// Optional: pass a causer other than the current user
$order->recordStateTransition('status', OrderStatus::SHIPPED, $causer: $systemUser);
Returns the new StateHistory entry, or null when both old and new state are null (no change).