AdAstra Overview

AdAstra: Project Overview

AdAstra is an opinionated content platform built for developers who want complete control over how content is modeled, managed, and delivered. Your content model comes first; from there, authoring experiences, APIs, routing, templates, workflows, and administration are all built around that model.

Built on Laravel 12, AdAstra provides an authenticated administration area, REST APIs, Twig templating, configurable content types, media management, and users, roles, and permissions.

This document is the canonical developer and operator reference for the platform. Throughout, every model that carries a developer-facing identifier uses a handle (not a slug): Field, FieldGroup, EntryGroup, EntryType, EntryBehavior, StatusGroup, Status, CategoryGroup, Category, Entry, and Media\Library.

Architecture at a Glance

AdAstra is a content platform whose structure is defined by your content model at runtime, built on Laravel 12. The core philosophy: all content structure is admin-defined at runtime. Entry types are backed by concrete PHP classes through the EntryBehavior registry; when no behavior is configured the registry falls back to GeneralEntryType. Everything else (fields, layouts, statuses, categories) is database-driven.

Repository layout. The platform ships as a Composer package, adastra/core, mounted at packages/core/ (PSR-4 AdAstra\packages/core/src/) via a path repository. The repository root is a thin Laravel host that keeps bootstrap/, public/, and host-level config/* (app.php, auth.php, fortify.php, …), and pulls the framework in as a dependency. All application classes, routes, migrations, seeders, admin views, and the test suite live inside the package; only config/settings.php and config/site.php moved into packages/core/config/.

FieldType          - system-level type registry. 23 types are seeded
                     (Text, Textarea, Html, Number, Date, EmailAddress, Url,
                      Telephone, ColorPicker, Relationship, Boolean, FileUpload,
                      Media, Select, MultiSelect, RadioGroup, Slider, Users,
                      StructuredRows, Money, Country, StateProvince, Time)
                     Each row stores the FQCN in `field_types.object`.
  └── Field        - admin-created field instances with settings (handle, label,
                     instructions, hidden, JSON settings)
        └── FieldGroup - reusable bundles of fields, attached to anything that
                         uses HasFieldGroups (EntryGroup, CategoryGroup,
                         UserSchema, Media\Library) via the polymorphic
                         field_groupables pivot

StatusGroup
  └── Status       - named statuses with handle, color, is_default, is_public.
                     Seeded groups: `publication`, `job-status`, `product-status`.

CategoryGroup     - owns a FieldLayout (HasFieldLayout) and FieldGroups
  └── Category    - hierarchical tree; uses Fieldable for custom values

FieldLayout
  └── Tab (field_layout_tabs)
        └── TabElement (field_layout_tab_elements) → Field

EntryGroup        - owns a FieldLayout, a StatusGroup, plus polymorphic
                    CategoryGroups and FieldGroups
  └── EntryType   - Schema: name, handle, entry_behavior_id (FK),
                    default_template, default_schema_type (SEO),
                    has_entry_tree, max_depth, allowed_parent_types,
                    field_layout_id (optional override), sort_order
        │   The PHP behaviour comes from the joined EntryBehavior row whose
        │   `class` column is a morph alias.
        └── Entry - title, handle, status_id + status_handle + status_is_public,
                    published_at, schema_type, created_by_user_id
              ├── FieldValue        - scalar custom field data (polymorphic)
              ├── EntryRelationship - relational field data (M2M to other Entries)
              ├── EntryAuthor (entry_authors) + entry_author_entry pivot
              │                   - explicit eligibility registry; only promoted
              │                     users appear in author pickers; pivot stores sort_order
              ├── categories        - polymorphic M2M (categorizables)
              └── EntryTree         - optional hierarchical URI tree node

Media\Library     - native upload container (adapter, allowed types, max size).
                    Owns polymorphic CategoryGroups, FieldGroups, and optional
                    FieldLayout.
  └── Media       - native file record with Fieldable, transformations,
                    categories() morphToMany, storage helpers, and soft deletes

UserSchema        - singleton (id=1) that owns a single FieldLayout and
                    one or more FieldGroups for ALL users
  └── User        - uses Fieldable, HasRoles (Spatie),
                    HasApiTokens (Sanctum), TwoFactorAuthenticatable (Fortify),
                    Notifiable; OAuth tokens via OauthToken HasMany;
                    account access controlled by five status values
                    (active / inactive / pending / suspended / banned)
                    plus a parallel locked_until column; status changes
                    audited in UserStatusLog (user_status_logs)

Request Lifecycle

A request enters the application through one of the standard route files:

File Prefix Middleware Purpose
packages/core/routes/admin.php /admin auth Twig-rendered admin UI
packages/core/routes/api.php /api/v1 auth:sanctum; per-resource LogRequestResponse REST API
packages/core/routes/web.php none web Public site catch-all plus auth/OAuth routes
packages/core/routes/console.php n/a n/a Scheduled jobs and console commands

Public site requests fall through to AdAstra\Http\Controllers\Site::show(), which delegates to AdAstra\Services\SiteRouting\SiteRouter::render($uri). The router tries the drivers in config('site.routing.priority') in order; the default order is entry_tree, then template.

Admin requests first pass the auth route middleware, then the base Admin\Controller constructor checks access admin. Write actions usually receive a FormRequest whose authorize() method checks the more specific Spatie permission for that operation.

API requests use Sanctum. Each API resource route is wrapped with LogRequestResponse; read and delete actions also tend to perform explicit controller-level $this->can(...) checks. Mutator actions often rely solely on the FormRequest's authorize() instead, an asymmetry worth keeping in mind when adding controller-level defence-in-depth for API writes.

Cross-cutting infrastructure

Polymorphic morph map. packages/core/src/Providers/AppServiceProvider.php registers short morph aliases via Relation::morphMap([...]). Stored aliases are entry, entry_group, entry_type, category, category_group, field_group, media, media_library, and user. This means field_values.fieldable_type will contain 'entry' rather than AdAstra\Models\Entry. Always use $model->getMorphClass() when writing polymorphic rows, not the FQCN.

Super-admin gate bypass. AppServiceProvider::boot() registers a Gate::before callback that returns true for any user with the super admin role, short-circuiting all permission checks. Every bypassed check is recorded to the append-only gate_bypass_logs audit table via the buffered GateBypassRecorder singleton (see Super Admin Gate Bypass).

Sanctum + Fortify + Spatie Permission + Socialite are wired together for auth: 2FA via Fortify's TwoFactorAuthenticatable, API tokens via Sanctum (HasApiTokens), RBAC via Spatie HasRoles, and OAuth login via Laravel\Socialite\Socialite (see packages/core/src/Http/Controllers/Login.php).

TwigBridge powers the admin views (packages/core/resources/views/admin/**/*.twig) alongside Blade templates elsewhere.


Setup

composer install
cp .env.example .env
php artisan migrate:fresh --seed
php artisan key:generate

php artisan key:generate runs last in this sequence intentionally: it's the final step before the app is considered ready, not a prerequisite for migrating/seeding. migrate:fresh --seed combines dropping/recreating all tables, running migrations, and running DatabaseSeeder into a single command (equivalent to running migrate:fresh then db:seed separately).

Set the database values in .env first, including DB_DATABASE, DB_USERNAME, and DB_PASSWORD. The example environment uses MySQL and a DB_TABLE_PREFIX of ada_.

The seeded super-admin's credentials come from .env, not from a fixed literal: DEV_USER_EMAIL, DEV_USER_NAME, and DEV_USER_PASSWORD are each read independently via config('app.default_dev_*') (config/app.php:126-128) and consumed by UsersSeeder (packages/core/database/seeders/UsersSeeder.php:27-32). If any of the three are left blank, that key alone falls back to str()->random(16): there is no shared fallback, so leaving all three blank does not reproduce a fixed known login. Set all three in .env if a predictable local login is wanted.

The DatabaseSeeder runs in this order (twelve always run; three more run only in local/testing):

  1. RolesPermissionsSeeder: permissions + 3 roles (super admin, admin, user)
  2. UsersSeeder: seeds a single super-admin user from DEV_USER_* env vars (see above); skipped when APP_ENV=production
  3. EntryBehaviorSeeder: 11 behaviour rows that bind entry_types to PHP classes via morph alias (must run before any entry_types row is created)
  4. FieldTypeSeeder: 23 field type rows (see Built-in Types)
  5. MediaLibrarySeeder: avatars library used by User::avatar(), plus any other seeded libraries
  6. StatusGroupSeeder: three status groups: publication (draft, published, archived), job-status (draft, published, expired, closed), and product-status (draft, published, out-of-stock, pre-order, discontinued)
  7. CategoryGroupSeeder: category groups + categories
  8. FieldGroupSeeder: field groups + fields (content-fields, seo-fields, relationship-fields, plus per-group field bundles)
  9. EntryGroupSeeder: blog and products entry groups, layouts, and types
  10. ExtendedEntryGroupSeeder: events, news, pages, jobs, podcast, portfolio, videos, recipes, plus a fallback general entry group
  11. UserSchemaSeeder: user profile schema (Profile and Bio tabs, fields like first_name, last_name, gender, date_of_birth, website, bio, social_twitter, social_linkedin)
  12. SettingsDomainSeeder: settings domains and system-level default values

Local/testing only:

  1. EntrySeeder: sample blog posts and products
  2. SandboxedEntryTreeSeeder: sample entry-tree nodes for routing tests
  3. SampleApiTokenSeeder: sample Sanctum tokens

Operational Commands and Deployment Notes

Useful commands:

composer install
npm install
php artisan migrate --force
php artisan db:seed
php artisan storage:link
php artisan adastra:doctor
php artisan adastra:validate-class-references
php artisan l5-swagger:generate
npm run build
composer test

The command list above is for ongoing deploys against an existing database: it deliberately uses migrate --force (apply new migrations only) rather than migrate:fresh (which drops every table). For first-time bring-up use the migrate:fresh --seed sequence in Setup above instead.

composer run dev starts three local processes through concurrently: Laravel's dev server, queue:listen --tries=1, and Vite.

Production deployments should run the Laravel scheduler every minute so the configured ApiLog prune job can execute at 02:00:

* * * * * cd /var/www/html && php artisan schedule:run >> /dev/null 2>&1

For queue-backed features, run a queue worker appropriate for the target environment. packages/core/routes/console.php schedules PruneApiLogs daily at 02:00 and PurgeDeletedMedia daily at 03:00; both require the scheduler cron to be active.

adastra:doctor produces a read-only health report for the whole installation — environment, database, migrations, storage, media, permissions, and stored class references — with exit codes deployment scripts can gate on (0 healthy / 2 failures; --strict promotes warnings to exit 1). Run it as the first step of any deploy or debugging session; see System Health and Data Integrity.

adastra:validate-class-references checks class-name strings stored in the database. It iterates entry_behaviors.class (morph alias keys): resolving each via Relation::getMorphedModel() and verifying the resulting class extends AbstractEntryType: and then iterates field_types.object (FQCN strings) verifying each class extends AbstractField. Exits with FAILURE if any reference is broken. It is a standalone wrapper over two doctor checks; the old app:validate-class-references name still works as an alias.

Running without php artisan serve (Apache/Nginx + PHP-FPM)

php artisan serve and composer run dev are development-only entry points. For an "upload the app and have it run" deployment, point a real web server at the existing public/ directory rather than the repo root:

Apache: vhost pointed at public/, honoring the existing .htaccess:

<VirtualHost *:80>
    ServerName example.com
    DocumentRoot /var/www/ad-astra/public

    <Directory /var/www/ad-astra/public>
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>

Nginx + PHP-FPM: no nginx config ships in this repo; a minimal block:

server {
    listen 80;
    server_name example.com;
    root /var/www/ad-astra/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

Production seeding caveat. UsersSeeder skips itself entirely when app()->environment('production') (see Setup above), and there is no dedicated make:admin-style console command: only DoctorCommand, RefreshTokens, and ValidateClassReferences exist under packages/core/src/Console/Commands/. A production "upload and run" deploy will not get an automatically seeded super-admin. Create the first admin manually after migrating, e.g.:

php artisan tinker
>>> $user = \AdAstra\Models\User::factory()->create(['email' => 'you@example.com', 'status' => 'active', 'password' => \Illuminate\Support\Facades\Hash::make('a-strong-password')]);
>>> $user->assignRole('super admin');

The scheduler cron and queue-worker requirements noted above apply to this deployment path as well: both are required regardless of which web server fronts the app.


Testing Strategy

Tests are split into PHPUnit Unit and Feature suites in phpunit.xml. The test environment forces APP_ENV=testing, uses database/testing.sqlite, sets queues to sync, sessions/cache to array drivers, and lowers bcrypt rounds for speed.

Run the suite with:

composer test

Coverage currently includes:


System Health and Data Integrity

The Doctor Command

php artisan adastra:doctor

The doctor command is the single, authoritative health report for an AdAstra installation. It runs every registered diagnostic check and prints a human-readable report grouped by subsystem — Environment, Database, Storage, Media, Permissions, Entry System, Field System — followed by a summary count.

Doctor is read-only by design: it never modifies state, never migrates, never repairs. It reports issues, explains why they matter, and prints the exact fix command where a safe one exists. Its output is also secrets-safe — presence and booleans only (APP_KEY exists, never the value) — so reports can be pasted into public GitHub issues for support.

The subsystem lives in packages/core/src/Doctor/: a DoctorCheck interface, an AbstractDoctorCheck convenience base, a dependency-aware DoctorRunner (a failed dependency cascades to skipped, not a wall of redundant failures; a crashing check becomes a fail result without taking doctor down), and table/JSON formatters. Checks register through the adastra.doctor.checks container tag, which is also the extension point (see Adding a New Doctor Check below).

Flags and Exit Codes

php artisan adastra:doctor                        # human-readable table
php artisan adastra:doctor --format=json          # machine-readable envelope
php artisan adastra:doctor --strict               # warnings fail the run
php artisan adastra:doctor --only=permissions     # filter by check/category id
php artisan adastra:doctor --except=media         # exclude by check/category id
Code Meaning
0 Healthy (warnings are OK unless --strict)
1 Warnings present and --strict was passed
2 Failures present (always, regardless of flags)

--only pulls dependencies in transitively: --only=permissions also runs database.connection and database.required-tables first, because a check never executes against unverified prerequisites. The JSON envelope carries a schema version, timestamp, AdAstra/Laravel/PHP versions, summary counts, and one entry per result — suitable for CI gates and support requests.

Tunables live in packages/core/config/doctor.php (publishable via vendor:publish --tag=adastra-config): the required_tables list and a disabled array for opting out of specific check ids. Check logic never lives in config.

Alpha Check Set

Category Check id Verifies
environment environment.php-version PHP meets the composer.json minimum (^8.2)
environment environment.laravel-version Laravel major version is supported
environment environment.app-key APP_KEY is set (presence only)
environment environment.app-debug warns when APP_DEBUG is on in production
environment environment.app-url warns when APP_URL is empty/localhost/non-HTTPS in production
database database.connection PDO connects; root dependency for all DB checks
database database.required-tables every table in config('doctor.required_tables') exists
database database.pending-migrations warns per unran migration
database database.stray-migrations warns per migrations row whose file is gone (version mismatch / rollback debt)
storage storage.writable real write+delete probe in storage/framework
storage storage.public-symlink every filesystems.links entry exists and resolves
cache cache.roundtrip put/get/forget probe on the default store (Settings and permission caching depend on it)
media media.transformation-driver warns when the null driver is bound (no imagick/gd — transformations silently no-op)
media media.fileinfo-extension warns when fileinfo is missing — upload MIME validation fails without it
media media.upload-limits warns per library whose max_size exceeds PHP's upload_max_filesize/post_max_size
media media.avatars-library the seeded avatars library exists (User::avatar() depends on it)
permissions permissions.required-roles every AccessSchema::requiredRoles() role exists
permissions permissions.required-permissions every AccessSchema::requiredPermissions() permission exists
permissions permissions.super-admin-assigned at least one user actually holds the super admin role (zero holders = bricked admin)
security security.dev-account-in-production warns when a user matching DEV_USER_EMAIL exists in production (presence only)
entry-system entry-system.behavior-classes every entry_behaviors.class morph alias resolves to an AbstractEntryType subclass
entry-system entry-system.duplicate-type-handles entry type handles are globally unique — Content::create() resolves by handle alone and the column has no unique index
entry-system entry-system.silent-behavior-fallback warns per type with no behavior (silently resolves to GeneralEntryType)
field-system field-system.type-classes every field_types.object FQCN resolves to an AbstractField subclass
templates templates.entry-templates every stored default_template/tree-node template (plus doctor.required_templates fallbacks) resolves to a real Twig view — currently in doctor.disabled while the template layer is in flux
assets assets.vite-manifest public/build/manifest.json exists in production (deploy skipped npm run build)

The permissions checks and RolesPermissionsSeeder share one source of truth: AdAstra\Support\AccessSchema. Adding a required role or permission there updates the seeder and the doctor simultaneously — no drift.

Adding a New Doctor Check

Any package or layer can contribute checks; new categories appear in the report automatically with no registration beyond the container tag. The complete worked example below adds a queue health check.

1. The check class — extend AbstractDoctorCheck, set $id and $name, implement run(). Yield one result per finding; yielding nothing counts as a pass. The category (queue) derives from the id prefix.

<?php

namespace AdAstra\Doctor\Checks\Queue;

use AdAstra\Doctor\AbstractDoctorCheck;
use Illuminate\Support\Facades\DB;

class FailedJobsCheck extends AbstractDoctorCheck
{
    protected string $id = 'queue.failed-jobs';
    protected string $name = 'Failed queue jobs';

    public function dependsOn(): array
    {
        // Anything touching the DB declares this — if the connection check
        // fails, this check reports as SKIP instead of a redundant error.
        return ['database.connection'];
    }

    public function run(): iterable
    {
        $count = DB::table('failed_jobs')->count();
        $threshold = (int) config('doctor.failed_jobs_warn_threshold', 25);

        if ($count > $threshold) {
            yield $this->warn(
                "{$count} failed jobs in the queue (threshold: {$threshold})",
                details: 'Jobs are failing and nobody is retrying or pruning them',
                fixCommand: 'php artisan queue:retry all   # or queue:flush to discard',
            );

            return;
        }

        yield $this->pass($count === 0 ? 'No failed jobs' : "{$count} failed jobs (under threshold)");
    }
}

The result vocabulary: $this->pass() (healthy), $this->warn() (works today, bites later), $this->fail() (broken install), $this->skip() (not applicable in this environment). warn/fail accept optional named arguments details, fixCommand, and docsUrl.

2. Registration — one line. Core checks are added to the existing tag block in AppServiceProvider::register(). A plugin or future layer (Shop, Search, third-party package) never touches core — it tags in its own service provider:

// e.g. packages/shop/src/Providers/ShopServiceProvider.php
public function register(): void
{
    $this->app->tag([
        FailedJobsCheck::class,
    ], 'adastra.doctor.checks');
}

The runner resolves the tag at execution time, so checks from every registered provider merge into one report.

3. Config tunable (optional) — knobs a host app might override go in packages/core/config/doctor.php (or the plugin's own config):

'failed_jobs_warn_threshold' => 25,

4. The test — instantiate the check directly and assert on the yielded results; no console plumbing:

<?php

namespace Tests\Unit\Doctor\Checks;

use AdAstra\Doctor\Checks\Queue\FailedJobsCheck;
use AdAstra\Doctor\DoctorStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class FailedJobsCheckTest extends TestCase
{
    use RefreshDatabase;

    public function test_passes_with_empty_failed_jobs_table(): void
    {
        $results = iterator_to_array((new FailedJobsCheck())->run(), false);

        $this->assertCount(1, $results);
        $this->assertSame(DoctorStatus::Pass, $results[0]->status);
    }

    public function test_warns_above_threshold(): void
    {
        config(['doctor.failed_jobs_warn_threshold' => 1]);

        foreach (range(1, 2) as $i) {
            DB::table('failed_jobs')->insert([
                'uuid' => "doctor-test-{$i}",
                'connection' => 'testing',
                'queue' => 'default',
                'payload' => '{}',
                'exception' => 'RuntimeException',
                'failed_at' => now(),
            ]);
        }

        $results = iterator_to_array((new FailedJobsCheck())->run(), false);

        $this->assertSame(DoctorStatus::Warn, $results[0]->status);
        $this->assertNotNull($results[0]->fixCommand);
    }
}

No registration test is needed: Tests\Unit\Doctor\DoctorChecksGuardTest walks every tagged check automatically and fails CI on malformed/duplicate ids, unresolvable dependsOn() entries, or dependency cycles.

What you get for free: table and JSON output, --only/--except filtering, --strict exit semantics, dependency skipping, and crash containment — a check author writes zero presentation code.

Three hard rules for every check (see docs/DOCTOR_EXTENDING.md for the full authoring guide): read-only (never fix anything), secrets-safe (output must be safe in a public issue), and environment-sensitive (yield skip, never fail, when the check doesn't apply — the test suite runs on SQLite, so a MySQL-only check must skip there).

Class Reference Validation

php artisan adastra:validate-class-references

Checks that every class-name string stored in the database still resolves to a real class. Iterates entry_behaviors.class (each row holds a morph alias such as behavior.blog-post): resolves via Relation::getMorphedModel() and verifies the result extends AbstractEntryType: then iterates field_types.object (FQCN strings) and verifies each extends AbstractField. Exits with FAILURE if any reference is broken: wire into CI before deploys.

The command is a thin wrapper over the two doctor checks (entry-system.behavior-classes and field-system.type-classes), which run as part of every full adastra:doctor report. The pre-rename app:validate-class-references signature still works as a hidden alias through alpha.

Morph Map Stability

Polymorphic stability via Eloquent Morph Maps in AppServiceProvider::boot():

// Polymorphic model aliases (used by *_type morph columns)
'entry' => Entry::class
'entry_group' => EntryGroup::class
'entry_type' => EntryType::class
'category' => Category::class
'category_group' => CategoryGroup::class
'field_group' => FieldGroup::class
'media' => Media::class
'media_library' => MediaLibrary::class
'user' => User::class

// EntryBehavior class aliases (stored in entry_behaviors.class)
'behavior.general' => GeneralEntryType::class
'behavior.blog-post' => BlogPostEntryType::class
'behavior.product' => ProductEntryType::class
'behavior.page' => PageEntryType::class
'behavior.event' => EventEntryType::class
'behavior.job-listing' => JobListingEntryType::class
'behavior.news-article' => NewsArticleEntryType::class
'behavior.podcast-episode' => PodcastEpisodeEntryType::class
'behavior.portfolio-item' => PortfolioItemEntryType::class
'behavior.recipe' => RecipeEntryType::class
'behavior.video' => VideoEntryType::class

Always rely on $model->getMorphClass() for new writes to polymorphic columns. The behavior.* aliases are a separate lookup table used only by EntryBehavior::instance() (see Entry Groups and Entry Types).


Users, Roles, and Permissions

The system uses Spatie Permission (spatie/laravel-permission) with the HasRoles trait on User. The User model also pulls in Notifiable, HasApiTokens (Sanctum), TwoFactorAuthenticatable (Fortify), and the project's Fieldable trait so users can store custom field values polymorphically.

Built-in Roles

Role Access
super admin Everything: bypasses all permission checks via Gate::before
admin Admin panel + seeded CRUD permissions for users, user tokens, roles, categories, entries, fields, field layouts, statuses, media libraries, settings, plus the api permission
user Admin panel access only (access admin permission only)

Built-in Permissions

The full permission list seeded by RolesPermissionsSeeder:

api
access admin

view user / create user / edit user / delete user
view user token / create user token / edit user token / delete user token

create category group / edit category group / delete category group / reorder category group
create category / edit category / delete category / reorder category

create media library / edit media library / delete media library / reorder media library

create role / edit role / delete role

create entry group / edit entry group / delete entry group
create entry type / edit entry type / delete entry type
create entry / edit entry / delete entry

create field group / edit field group / delete field group
create field / edit field / delete field
create field layout / edit field layout / delete field layout

create status / edit status / delete status

manage user status

edit setting

The seeded admin role receives every permission above. The seeded user role receives only access admin. The super admin role bypasses all checks via Gate::before, so it does not need explicit permission assignments.

Super Admin Gate Bypass

AppServiceProvider::boot() registers a Gate::before callback that short-circuits every authorization check for users holding the super admin role:

Gate::before(function ($user, $ability, array $arguments = []) {
    if (!$user->hasRole('super admin')) {
        return null; // defer to normal authorization
    }

    app(GateBypassRecorder::class)->record($user, $ability, $arguments);

    return true;
});

Because Gate::before runs ahead of every gate and policy, this covers can(), Gate::authorize(), FormRequest authorize(), and Blade/Twig permission checks alike — web admin and API. Non-super-admins hit the early return before any extra work happens. Spatie caches the roles relation on the model instance, so repeated hasRole() calls within a request do not re-query.

Assignment guard. Only an existing super admin may grant the role: UserService::syncRoles() rejects any sync containing super admin when the acting user does not hold it.

Gate Bypass Audit Trail

Because the bypass sidesteps the entire permission system, every check it short-circuits is recorded to the append-only gate_bypass_logs table. AdAstra\Services\GateBypassRecorder (a singleton) buffers bypasses in-memory during the request — deduplicating identical checks (same user + ability + subject) into one row with an occurrences count, since a single admin page render can fire dozens of gate checks — and writes one bulk insert after the response is sent (app()->terminating(), plus Queue::after/Queue::failing for queue workers). Recording is failure-isolated: any throwable in the recorder is report()ed and swallowed, so audit logging can never break authorization or the request.

Column Notes
user_id The super admin; nullable FK with nullOnDelete so audit rows outlive the account
ability The gate/permission name that was bypassed
subject_type / subject_id Morph alias via getMorphClass() + string key; class-level checks (e.g. create) store the alias with a null id; argument-less abilities store null/null
method / url / route_name / ip Request context; all null for console/queue bypasses
occurrences Dedupe count for identical checks within one request
context JSON; console command line or queue job class for non-HTTP bypasses
created_at Append-only — no updated_at

The model is AdAstra\Models\GateBypassLog with user() and a subject() MorphTo relation that resolves back through the morph map.

Read requests are skipped by default. Bypasses during GET/HEAD/OPTIONS requests are almost entirely passive UI checks (navigation visibility, button rendering), so they are not recorded unless the Include Read Requests setting is enabled. Console and queue bypasses always record — rare and high-signal.

Behavior is governed by the security settings domain (packages/core/config/settings.php), read via Settings::system() so the audited user's own overrides cannot influence an audit control:

Setting Default Purpose
gate_bypass_log_enabled true Master switch for the audit trail
gate_bypass_log_include_reads false Also log bypasses on GET/HEAD/OPTIONS
gate_bypass_log_retention_days 365 Prune window; 0 keeps rows forever

GateBypassLog uses the Prunable trait; AdAstra\Jobs\PruneGateBypassLogs runs daily at 02:10 (see packages/core/routes/console.php) and respects the retention setting.

Roadmap note: docs/AUDIT_PLAN.md describes a unified audit log that will absorb this subsystem — bypasses become metadata on mutation audit records instead of a standalone table. Until that lands, gate_bypass_logs is the authoritative record of super-admin bypass activity.

Creating Users Programmatically

use AdAstra\Facades\Users;

$user = Users::create([
    'name' => 'Jane Doe',
    'email' => 'jane@example.com',
    'password' => 'secret-passphrase', // hashed by UserService
    'roles' => ['admin'],
    'fields' => [
        'first_name' => 'Jane',
        'last_name' => 'Doe',
    ],
]);

If you need raw Eloquent:

use AdAstra\Models\User;
use Illuminate\Support\Facades\Hash;

$user = User::create([
    'name' => 'Jane Doe',
    'email' => 'jane@example.com',
    'password' => Hash::make('secret-passphrase'),
]);
$user->assignRole('admin');

Checking Permissions

if ($user->can('edit category')) { /* ... */ }
if ($user->hasRole('super admin')) { /* ... */ }
if ($user->hasAnyRole(['admin', 'super admin'])) { /* ... */ }

Creating a New Permission and Role

use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

$permission = Permission::create([
    'name' => 'publish entry',
    'description' => 'Allows user to set entries to published status',
]);

$role = Role::create(['name' => 'editor']);
$role->givePermissionTo(['access admin', 'publish entry']);

// Or give a permission directly to a user
$user->givePermissionTo('publish entry');

Adding New Permissions

Core admin permissions are seeded in RolesPermissionsSeeder. Add a new permission only for behavior that is not already covered by the built-in list, such as workflow-specific capabilities.

use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

// php artisan db:seed --class=WorkflowPermissionsSeeder
class WorkflowPermissionsSeeder extends Seeder
{
    public function run(): void
    {
        $permissions = [
            ['name' => 'publish entry', 'description' => 'Set entries to published status'],
            ['name' => 'archive entry', 'description' => 'Move entries to archived status'],
        ];

        foreach ($permissions as $p) {
            Permission::firstOrCreate(['name' => $p['name']], $p);
        }

        Role::findByName('admin')->givePermissionTo([
            'publish entry', 'archive entry',
        ]);

        $editor = Role::firstOrCreate(['name' => 'editor']);
        $editor->givePermissionTo([
            'access admin', 'create entry', 'edit entry', 'publish entry', 'archive entry',
        ]);
    }
}

Gate checks work immediately after seeding:

$this->authorize('publish entry');             // controller
if (auth()->user()->can('publish entry')) { }  // inline

The super admin role bypasses every permission via Gate::before.


User Account Status

Status Values

The User model carries a status column with five administrative values defined in AdAstra\Enums\UserStatus:

Value Constant Meaning
active UserStatus::ACTIVE Full access
inactive UserStatus::INACTIVE Disabled by an admin; cannot log in
pending UserStatus::PENDING Awaiting approval; cannot log in
suspended UserStatus::SUSPENDED Time-limited block; lifts automatically when suspended_until passes
banned UserStatus::BANNED Permanent block; banned_at timestamp is recorded

Status is separate from roles. Roles define what an active user may do; status governs whether they can enter the system at all. The default status for newly created users is driven by the users.default_status setting (fallback: active). Social-login registrations use users.social_default_status (fallback: pending).

UserStatus::ALL contains every valid value and is used with Rule::in(UserStatus::ALL) in validation. UserStatus::CREATION_ALLOWED (active, inactive, pending) lists values permitted at creation time; suspended and banned are post-creation admin actions only.

Parallel Lock Column

locked_until is a nullable datetime column orthogonal to status. An active user with a future locked_until cannot access the system. The lock expires automatically at runtime: no cron or scheduler is needed.

Column Purpose
status Administrative state (UserStatus::ALL)
suspended_until Set by suspend(); checked at runtime for auto-expiry
banned_at Timestamp set when status becomes banned, cleared otherwise
locked_until Parallel lock independent of status; checked on every request

Model Helpers

$user->canAccessSystem();    // true if active + not locked (auto-expires)
$user->isLocked();           // true if locked_until is in the future
$user->isSuspended();        // true if suspended and suspended_until is future
$user->statusLabel();        // human-readable label e.g. "Pending Approval"
$user->statusColour();       // Tailwind token: 'emerald', 'amber', 'orange'…
$user->accessDeniedReason(); // lang key string, or null if access is allowed
$user->statusLogs();         // HasMany → UserStatusLog, newest-first

canAccessSystem() handles suspension auto-expiry inline: a suspended user whose suspended_until has passed is treated as active without any database write. The column is cleaned up on the next explicit status change.

Authentication Gate

FortifyServiceProvider registers an authenticateUsing callback that calls canAccessSystem() before completing login. Non-active users receive a ValidationException with a translated error key from lang/en/auth.php: they are never authenticated and no session is created.

auth.account_inactive   - inactive
auth.account_pending    - pending
auth.account_suspended  - suspended (within window)
auth.account_banned     - banned
auth.account_locked     - locked (regardless of status)

Access-Enforcement Middleware

Two middleware classes enforce status on every request after login:

Middleware Stack Blocked response
EnforceUserStatus web Logout + redirect to login with error flash
EnforceUserStatusApi api 403 JSON; stateful fallback: logout + redirect

Both are appended in bootstrap/app.php and fire on every request, so a status change takes effect immediately on the user's next page load or API call.

Status Change Events and Audit Log

Every status or lock change fires an event caught by WriteUserStatusLog, which records a row in user_status_logs:

Event Fired by
UserStatusChanged UserService::setStatus(), UserService::suspend()
UserLockChanged UserService::lockUser(), UserService::unlockUser()

user_status_logs stores user_id, changed_by_user_id, previous_status, new_status, previous_locked_until, new_locked_until, reason, context (JSON: e.g. suspended_until timestamp), and created_at. The model is AdAstra\Models\UserStatusLog with actor() (the admin who made the change) and user() belongs-to relations.

Admin UI

The user show view (packages/core/resources/views/admin/users/show.twig) includes a Status & Access card visible only to users with the manage user status permission. It provides:

Admin routes:

Method URL Route name Action
PATCH /admin/users/{id}/status users.status.update Change status / suspend
DELETE /admin/users/{id}/lock users.lock.destroy Remove lock immediately

Both routes are handled by AdAstra\Http\Controllers\Admin\UserStatusController and gated on the manage user status permission via AdAstra\Http\Requests\User\UserStatusRequest.


User Extended Profile (UserSchema)

UserSchema is a singleton (user_schema.id = 1) that owns a single FieldLayout applied to all users. The read API ($user->field('handle')) is identical to entries; writes go through Users::setField() / Users::setFields() (or the PersistsFieldValues concern).

UserSchema::instance();      // eager-loads fieldLayout, request-scoped cache
UserSchema::flushResolved(); // clear cache (useful in tests)

Setting Up the User Schema

UserSchemaSeeder creates two FieldGroups and a two-tab layout:

FieldGroup handle Fields
User Profile user-profile first_name, last_name, gender, date_of_birth, website
User Bio user-bio bio, social_twitter, social_linkedin
php artisan db:seed --class=UserSchemaSeeder

Manual setup:

use AdAstra\Models\Field;
use AdAstra\Models\Field\Group as FieldGroup;
use AdAstra\Models\Field\Type as FieldType;
use AdAstra\Models\FieldLayout;
use AdAstra\Models\FieldLayout\Tab;
use AdAstra\Models\FieldLayout\TabElement;
use AdAstra\Models\UserSchema;

$text = FieldType::where('object', \AdAstra\Field\Types\Text::class)->firstOrFail();

$firstName = Field::firstOrCreate(
    ['handle' => 'first_name'],
    [
        'field_type_id' => $text->id,
        'name' => 'First Name',
        'label' => 'First Name',
    ]
);

$lastName = Field::firstOrCreate(
    ['handle' => 'last_name'],
    [
        'field_type_id' => $text->id,
        'name' => 'Last Name',
        'label' => 'Last Name',
    ]
);

$group = FieldGroup::firstOrCreate(
    ['handle' => 'user-profile'],
    ['name' => 'User Profile']
);

$group->fields()->syncWithoutDetaching([$firstName->id, $lastName->id]);

$layout = FieldLayout::create(['name' => 'User Profile Layout']);
$tab = Tab::create(['field_layout_id' => $layout->id, 'name' => 'Profile', 'sort_order' => 1]);

foreach ([$firstName, $lastName] as $i => $field) {
    TabElement::create([
        'field_layout_tab_id' => $tab->id,
        'field_id' => $field->id,
        'required' => false,
        'sort_order' => $i + 1,
    ]);
}

$schema = UserSchema::instance();
$schema->field_layout_id = $layout->id;
$schema->save();
$schema->fieldGroups()->syncWithoutDetaching([$group->id]);

Writing Field Values to a User

use AdAstra\Facades\Users;

Users::setFields($user, [
    'first_name' => 'Jane',
    'last_name' => 'Doe',
]);

Do not hard-code User::class as fieldable_type: the morph map stores 'user' instead. Use $user->getMorphClass():

use AdAstra\Models\Field;
use AdAstra\Models\FieldValue;

$field = Field::where('handle', 'first_name')->firstOrFail();

FieldValue::updateOrCreate(
    [
        'field_id' => $field->id,
        'fieldable_id' => $user->getKey(),
        'fieldable_type' => $user->getMorphClass(), // 'user'
    ],
    [$field->fieldType->instance()->storageColumn() => 'Jane']
);

Reading Field Values from a User

$user = User::with('fieldValues.field.fieldType')->find(1);

echo $user->field('first_name'); // 'Jane'
echo $user->field('last_name');  // 'Doe'

Typical Controller Pattern

public function show(User $user): array
{
    $user->load('fieldValues.field.fieldType');
    return [
        'name' => $user->name,
        'first_name' => $user->field('first_name'),
        'last_name' => $user->field('last_name'),
    ];
}

public function update(Request $request, User $user): void
{
    \AdAstra\Facades\Users::update($user, [
        'fields' => $request->input('fields', []),
    ]);
}

Comparison: Users vs Entries

Entries Users
Write API Content::create() / Content::update() Users::create() / Users::update()
Read API $entry->field('handle') $user->field('handle')
Schema Per-group FieldLayout + per-type FieldLayout (merged) Single UserSchema singleton
Lifecycle hooks beforeCreate, afterCreate, etc. on EntryType class None
Custom fields Scalar + Relational Scalar only

Author Eligibility

Not every user can be assigned as an entry author. The Author Eligibility Layer decouples the concept of "a user who may write content" from the plain users table. A user becomes eligible for entry author pickers only when an EntryAuthor record exists for them with status = 'active'. This record is the single source of truth; it is never inferred from roles or any other attribute.

Schema

Two tables implement the layer:

Table Purpose
entry_authors Eligibility registry. One row per eligible user. Columns: user_id (unique FK), display_name (nullable), status (active / pending / disabled).
entry_author_entry Pivot. Links entries to their assigned authors. Columns: entry_id, entry_author_id, sort_order, timestamps.

entry_authors is intentionally separate from users. Demoting a user sets status = 'disabled' but preserves the row (and all existing entry assignments) so historical data is never orphaned.

The EntryAuthor model (AdAstra\Models\EntryAuthor) provides three query scopes:

EntryAuthor::active()->get();    // status = 'active'
EntryAuthor::pending()->get();   // status = 'pending'
EntryAuthor::disabled()->get();  // status = 'disabled'

The display_name accessor falls back gracefully:

EntryAuthor::display_name → explicit stored value → user->name → ''

EntryAuthorService and EntryAuthors Facade

AdAstra\Services\EntryAuthorService is the only place that writes to entry_authors. Inject it or use the EntryAuthors facade (AdAstra\Facades\EntryAuthors):

Method Signature Description
getEligible() (): Collection All active records with user eager-loaded, ordered by display_name. The only source entry author pickers should ever read from.
findByUser() (User $user): ?EntryAuthor Look up the eligibility record for a user regardless of status.
promote() (User $user, ?string $displayName = null): EntryAuthor Create or reactivate the record. Pass null to leave an existing display_name untouched; pass '' to clear it.
demote() (User $user): void Set status = 'disabled'. Does not delete the record or touch existing entry assignments.
sync() (User $user, bool $eligible, ?string $displayName = null): ?EntryAuthor Idempotent upsert: calls promote() when $eligible is true, demote() otherwise.

Promoting and Demoting Authors

use AdAstra\Facades\EntryAuthors;

// Promote a user (creates the record if needed, or reactivates it)
$entryAuthor = EntryAuthors::promote($user);

// Promote with a display name override
$entryAuthor = EntryAuthors::promote($user, 'Jane Doe, Staff Writer');

// Demote (disables but does not delete)
EntryAuthors::demote($user);

// Idempotent sync - use this from admin forms
EntryAuthors::sync($user, eligible: true, displayName: 'Pen Name');
EntryAuthors::sync($user, eligible: false);

// Check eligibility on a User instance
$user->load('entryAuthor');
$user->isAuthorEligible(); // true only when status === 'active'

The seeder promotes eric@mithra62.com automatically:

// UsersSeeder
$user->assignRole('super admin');
app(EntryAuthorService::class)->promote($user);

Author Eligibility via UserService

UserService::create() and UserService::update() accept two optional keys that are stripped from the user attributes and delegated to EntryAuthorService::sync():

Key Type Behaviour
is_author bool When true, calls promote(); when false, calls demote(). Absent = no-op.
author_display_name ?string Passed as the display name to promote(). Only used when is_author is also present.
use AdAstra\Facades\Users;

// Create a user and immediately promote them as an author
$user = Users::create([
    'name' => 'Jane Doe',
    'email' => 'jane@example.com',
    'password' => 'secret',
    'is_author' => true,
    'author_display_name' => 'J. Doe',
]);

// Update an existing user, removing author eligibility
Users::update($user, ['is_author' => false]);

// Update name without touching eligibility (key absent = no sync)
Users::update($user, ['name' => 'Jane Smith']);

The admin Create User and Edit User forms wire these keys through their Twig views and StoreUserRequest / EditUserRequest validation rules.

Querying Eligible Authors

The admin entry form (Create / Edit) populates its author picker by calling EntryAuthors::getEligible():

use AdAstra\Facades\EntryAuthors;

$authors = EntryAuthors::getEligible();
// Returns: Collection of EntryAuthor, only status='active', with user eager-loaded
// Ordered by display_name ASC

foreach ($authors as $author) {
    echo $author->user_id;       // FK back to users
    echo $author->display_name;  // falls back to user->name
}

Entry author assignment passes user IDs (not entry_author_id values) through request validation. EntryRepository::syncAuthors() resolves them to active EntryAuthor IDs before writing to the entry_author_entry pivot: this is the double-gate: a user who has been demoted between page-load and form submission is silently dropped from the sync.

Validation rule in StoreEntryRequest / EditEntryRequest:

'authors.*' => ['integer', Rule::exists('entry_authors', 'user_id')->where('status', 'active')],

UserService and the Users Facade

CRUD

use AdAstra\Facades\Users;

$user = Users::create([
    'name' => 'Jane Doe',
    'email' => 'jane@example.com',
    'password' => 'secret',
    'roles' => ['admin'],
    'fields' => ['first_name' => 'Jane', 'last_name' => 'Doe'],
    // Optional author eligibility keys (stripped before User::create()):
    'is_author' => true,
    'author_display_name' => 'J. Doe',
]);

$user = Users::update($user, [
    'name' => 'Jane Smith',
    'roles' => ['user'],
    'fields' => ['last_name' => 'Smith'],
    // Optional - omit to leave eligibility unchanged:
    'is_author' => false,
]);

Users::delete($user);

Roles

Users::assignRoles($user, 'editor');           // additive
Users::assignRoles($user, ['editor', 'writer']);
Users::syncRoles($user, ['admin']);            // replaces all
Users::revokeRole($user, 'editor');

Custom Fields

Users::setField($user, 'bio', 'Staff engineer at Acme.');
Users::setFields($user, ['first_name' => 'Jane', 'last_name' => 'Smith']);

$user->load('fieldValues.field.fieldType');
echo $user->field('first_name');

Passwords

Users::setPassword($user, 'newpassword123'); // admin force-set

app(\AdAstra\Actions\User\UpdateUserPassword::class)->update($user, [
    'current_password' => 'oldpassword',
    'password' => 'newpassword123',
    'password_confirmation' => 'newpassword123',
]);

Status Management

use AdAstra\Enums\UserStatus;

// Set any non-suspension status; manages banned_at automatically
Users::setStatus($user, UserStatus::INACTIVE, 'Account deactivated');
Users::setStatus($user, UserStatus::BANNED, 'Violated terms of service');
Users::setStatus($user, UserStatus::ACTIVE);   // reason optional for active

// Suspend for a fixed window
Users::suspend($user, new DateTime('+7 days'), 'Repeated spam posts');

// Lock account temporarily (parallel to status - does not change status)
Users::lockUser($user, new DateTime('+30 minutes'), 'Too many failed logins');
Users::unlockUser($user);                          // clear lock immediately

setStatus() fires UserStatusChanged and keeps banned_at and suspended_until in sync automatically. suspend() fires UserStatusChanged with context['suspended_until']. lockUser() / unlockUser() fire UserLockChanged. All four methods write to user_status_logs via the WriteUserStatusLog listener registered in AppServiceProvider.

Status changes made through UserService::update() are intentionally ignored: the status, suspended_until, banned_at, and locked_until keys are stripped from the update payload. Always use the dedicated status methods above.

Two-Factor Authentication

$setup = Users::enableTwoFactor($user);
// $setup['qr_code_svg'], $setup['secret']

Users::confirmTwoFactor($user, '123456'); // throws ValidationException if wrong
Users::hasTwoFactor($user);              // true after confirmation

$codes = Users::getRecoveryCodes($user);
$newCodes = Users::regenerateRecoveryCodes($user);
Users::disableTwoFactor($user);

OAuth Token Management

$token = Users::upsertOauthToken($user, 'google', [
    'access_token' => 'ya29.xxx',
    'refresh_token' => '1//xxx',
    'expires_at' => now()->addHour(),
    'scopes' => ['email', 'profile'],
    'provider_user_id' => '1234567890',
]);

$token = Users::getActiveOauthToken($user, 'google');
if ($token?->isExpired()) { /* refresh */ }

Users::revokeOauthToken($token);
Users::revokeAllOauthTokens($user, 'google');
Users::revokeAllOauthTokens($user);

$tokens = Users::listOauthTokens($user);

Action Classes Inventory

Entry (packages/core/src/Actions/Entry/)

Class Method
CreateNewEntry create(array $input): Entry: reads $input['type_handle'], delegates to Content::create()
UpdateEntry update(Entry $entry, array $input): Entry
Group/CreateNewEntryGroup create(array $input): EntryGroup
Group/EditEntryGroup edit(EntryGroup $group, array $input): EntryGroup
Type/CreateNewEntryType create(string|int $groupId, array $input): EntryType
Type/EditEntryType edit(EntryType $type, array $input): EntryType
Tree/CreateEntryTreeNode create(Entry $entry, string $handle, ?EntryTree $parent = null, ?string $template = null, bool $isHome = false): EntryTree
Tree/MoveEntryTreeNode handle(EntryTree $node, ?EntryTree $newParent, int $sortOrder = 0): EntryTree
Tree/RebuildEntryTreeUri handle(EntryTree $node): void
RecordEntryMetric record(Entry $entry, string $metric, int $value = 1, ?Carbon $date = null): EntryMetric

Category (packages/core/src/Actions/Category/)

Class Method
CreateNewCategory create(array $input): Category
EditCategory edit(Category $category, array $input): Category
Group/CreateNewCategoryGroup create(array $input): CategoryGroup
Group/EditCategoryGroup edit(CategoryGroup $group, array $input): bool

Field (packages/core/src/Actions/Field/)

Class Method
CreateNewField create(array $input): Field; createByGroup(array $input): Field
EditField edit(Field $field, array $input): bool
Group/CreateNewFieldGroup create(array $input): FieldGroup
Group/EditFieldGroup edit(FieldGroup $group, array $input): bool

FieldLayout (packages/core/src/Actions/FieldLayout/)

Class Method
CreateNewFieldLayout create(array $input): FieldLayout
EditFieldLayout edit(FieldLayout $layout, array $input): FieldLayout
DeleteFieldLayout delete(FieldLayout $layout): bool
Tab/CreateNewTab create(FieldLayout $layout, array $input): Tab
Tab/EditTab edit(Tab $tab, array $input): Tab
Tab/DeleteTab delete(Tab $tab): bool
Tab/Element/CreateTabElement create(Tab $tab, array $input): TabElement
Tab/Element/EditTabElement edit(TabElement $element, array $input): TabElement
Tab/Element/DeleteTabElement delete(TabElement $element): bool

Media (packages/core/src/Actions/Media/Library/)

Class Method
CreateNewMediaLibrary create(array $input): Library: attaches $input['category_groups']
EditMediaLibrary edit(Library $library, array $input): bool: re-syncs category groups and field groups
DeleteMediaLibrary delete(Library $library): bool
UploadMedia upload(FormRequest $request, Library $library): Media

Status (packages/core/src/Actions/Status/)

Class Method
CreateNewStatus create(array $input): Status; createByGroup(array $input): Status
EditStatus edit(Status $status, array $input): bool
Group/CreateNewStatusGroup create(array $input): StatusGroup
Group/EditStatusGroup edit(StatusGroup $group, array $input): bool

Role (packages/core/src/Actions/Role/)

Class Method
CreateNewRole create(array $input): Role
EditRole edit(Role $role, array $input): void

User (packages/core/src/Actions/User/)

Class Method
CreateNewUser create(array $input): User
UpdateUserProfileInformation update(User $user, array $input): User
UpdateUserPassword update(User $user, array $input): void: verifies current password
ResetUserPassword reset(User $user, array $input): void: no current-password check
Token/CreateNewUserToken create(User $user, array $input): NewAccessToken

Settings (packages/core/src/Actions/Settings/)

Class Method
UpdateDomainSettings execute(string $handle, array $data): void
UpdateUserSettings execute(User $user, array $data): void

OAuth and Social Login

Social login is handled through Laravel Socialite. The User model owns OAuth records through user_oauth_tokens, represented by AdAstra\Models\User\OauthToken.

OauthToken stores provider identity (provider, provider_account, provider_user_id), OIDC identity (issuer, subject, id_token), token values (access_token, refresh_token, token_type, expires_at), scopes, metadata, and revocation/usage timestamps.

TokenRefreshService refreshes tokens when they are expired or close to expiry. It requires:

tryRefresh() logs and swallows refresh failures. refresh() throws TokenRefreshException on revoked tokens, missing refresh tokens, missing provider config, HTTP failures, or invalid provider responses.

The app:refresh-tokens console command is implemented and refreshes expiring OAuth tokens through TokenRefreshService. It is not currently scheduled in packages/core/routes/console.php, so automatic refresh requires either a schedule entry or an external caller.


System and User Settings

Settings are config-defined and value-backed. Domain and field definitions live in packages/core/config/settings.php; persisted values live in setting_values; domain navigation metadata lives in setting_domains.

Do not use AdAstra\Models\Settings. That class is a tombstone and throws at runtime. Use the AdAstra\Settings service through constructor injection, app(\AdAstra\Settings::class), or the container alias app('settings').

Settings Domains

Each top-level key in packages/core/config/settings.php is a domain handle. The current domains are:

Domain Purpose
general Site name, timezone, date/time formats, admin pagination
media Upload size, allowed extensions, image quality
email Outbound sender and reply-to defaults
content Content listing and default entry behavior
security Security auditing: super-admin gate bypass logging and retention

SettingsDomainSeeder creates the setting_domains rows and pre-seeds system-level values for fields with non-null defaults. It uses Settings::set(), so values are written to the correct typed column.

Field definitions support these keys:

Key Notes
handle Unique within the domain; stored as setting_values.field_handle
label Admin UI label and validation attribute name
type text, integer, float, boolean, or json
default Returned when no stored system or user value exists
rules Laravel validation rules; nullable is prepended unless required exists
instructions Admin UI helper text
group Optional admin UI grouping label
hidden Hidden fields are excluded from the admin forms
user_overridable Allows authenticated users to save a personal override

Value Storage and Resolution

setting_values stores one row per (domain, field_handle, user_id):

domain / field_handle / user_id
value_text | value_integer | value_float | value_boolean | value_json

user_id = NULL means system-level value. A non-null user_id means a per-user override. The AdAstra\Settings service resolves values in this order:

user override -> system value -> config default

Typed storage is routed by Settings::columnFor():

Type Column
text value_text
integer value_integer
float value_float
boolean value_boolean
json value_json

System values are cached for one hour under settings.system.{domain}. User overrides are cached for one hour under settings.user.{userId}.{domain}. set() and setMany() bust only the relevant cache key; bustDomain() clears the system cache and every known user cache for that domain.

Reading Settings

use AdAstra\Settings;

$settings = app(Settings::class);

// Current authenticated user overrides are applied automatically.
$timezone = $settings->get('general', 'timezone', 'UTC');

// Resolve all values for the current user.
$general = $settings->all('general');

// Resolve all values for a specific user.
$generalForUser = $settings->all('general', $user);

// System values only; user overrides are ignored.
$systemGeneral = $settings->system('general');

Settings::get() accepts a default fallback for unknown handles. For known handles without stored values, the config field default wins.

Writing System Settings

System settings are managed under /admin/settings:

Route Purpose Authorization
GET /admin/settings List setting domains access admin via admin controller
GET /admin/settings/{handle} Edit one domain access admin via admin controller
PUT /admin/settings/{handle} Save one domain edit setting via UpdateDomainSettingsRequest

The request builds validation rules from packages/core/config/settings.php. Boolean fields are normalised from checkbox presence and skipped during validation; optional non-boolean fields automatically receive nullable.

use AdAstra\Actions\Settings\UpdateDomainSettings;

app(UpdateDomainSettings::class)->execute('general', [
    'site_name' => 'Laravel Base',
    'timezone' => 'America/Phoenix',
    'date_format' => 'Y-m-d',
    'time_format' => 'H:i',
    'items_per_page' => 25,
]);

Equivalent lower-level service call:

app(\AdAstra\Settings::class)->setMany('general', [
    'site_name' => 'Laravel Base',
], user: null);

Writing User Preferences

User preferences are managed under /admin/account/settings:

Route Purpose Authorization
GET /admin/account/settings Edit current user's preferences account.settings
PUT /admin/account/settings Save current user's preferences account.edit_settings; authenticated user via UpdateUserSettingsRequest

Only fields with user_overridable => true and hidden => false are shown, validated, and written. Submitted values for non-overridable fields are ignored. Current user-overridable fields are timezone, date_format, time_format, and items_per_page in the general domain.

use AdAstra\Actions\Settings\UpdateUserSettings;

app(UpdateUserSettings::class)->execute($user, [
    'timezone' => 'America/Phoenix',
    'date_format' => 'm/d/Y',
    'time_format' => 'g:i A',
    'items_per_page' => 50,
]);

To write a single override directly:

app(\AdAstra\Settings::class)->set('general', 'timezone', 'America/Phoenix', $user);

Adding a Setting

Add a field definition to the appropriate domain in packages/core/config/settings.php. No migration is required. If the setting needs a pre-seeded system value, give it a non-null default and run:

php artisan db:seed --class=SettingsDomainSeeder

For a new domain, add a new top-level config key with name, description, icon, sort_order, and fields, then run the same seeder so the setting_domains row is created. Mark user_overridable as true only for settings that should appear on the current user's preferences screen.


Field Types

Field types are PHP classes in packages/core/src/Field/Types/ that extend AbstractField (packages/core/src/Field/AbstractField.php). They are registered in the field_types table. Each row stores the fully-qualified class name in field_types.object; instantiation goes through Field\Type::instance(), which validates class_exists() and is_subclass_of(AbstractField::class).

AbstractField methods subclasses can override:

Method Purpose
storageColumn(): string Required. One of value_text, value_integer, value_float, value_date, value_boolean, value_json.
isRelational(): bool Default false. Return true to route writes to entry_relationships.
cast(mixed $value): mixed Default identity. Convert raw stored value before returning.
validate(mixed $value): bool|string Default true. Return error string on failure.
render(array $params): string Render a Blade partial for the admin form.
getRules(): array Return Laravel validation rules.

Built-in Types

All 23 types are registered in packages/core/database/seeders/FieldTypeSeeder.php. Each row stores the fully-qualified class name in field_types.object.

Class Twig partial storageColumn() Notes
Text _fields/text.twig value_text Single-line input
Textarea _fields/textarea.twig value_text Multi-line
Html _fields/html.twig value_text Rich-text; sanitized via HTML Purifier on write
Number _fields/number.twig value_integer or value_float Branches on decimals setting
Date _fields/date.twig value_date Cast as datetime; reads return Carbon
Time _fields/time.twig value_text Time-of-day (HH:MM or HH:MM:SS); value() returns AdAstra\Support\Iso\TimeValue
EmailAddress _fields/email.twig value_text
Url _fields/url.twig value_text
Telephone _fields/telephone.twig value_text
ColorPicker _fields/color_picker.twig value_text Hex value
Boolean _fields/boolean.twig value_boolean Casts reads to bool
Relationship _fields/relationship.twig (none: relational) isRelational() === true; stores in entry_relationships
FileUpload _fields/file_upload.twig value_json IDs synced to mediables pivot by FieldValueObserver
Media _fields/media.twig value_json Media picker variant
Select _fields/select.twig value_text
MultiSelect _fields/multi_select.twig value_json
RadioGroup _fields/radio_group.twig value_text
Slider _fields/slider.twig value_integer or value_float
Users _fields/users.twig value_json Picker for user IDs
StructuredRows _fields/structured_rows.twig value_json Repeatable rows; columns declared in field settings
Money _fields/money.twig value_integer Stored as integer minor units; value() returns Money\Money object; currency from field settings
Country _fields/country.twig value_text ISO 3166-1 alpha-2 country code
StateProvince _fields/state_province.twig value_text ISO 3166-2 subdivision code

Write pipeline at a glance

Understanding which method runs when matters for any field-level hardening:

HTTP POST/PUT
  └── FormRequest
        └── rules() returns merge of static + schemaFieldRules()
              └── For each layout element:
                    'fields.<handle>' => $field->typeInstance()->getRules()
                                       merged with [required] / [nullable]
        └── Laravel validation fires using the merged rules
                                  ↓
                            (on success)
                                  ↓
  └── Controller → Action → Service
        └── EntryRepository / AbstractFieldableRepository
              └── applyFieldValues($model, $fields)
                    For each handle in the submitted payload:
                      ├── $field->typeInstance()->storageColumn()
                      ├── $field->typeInstance()->prepareForStorage($value)
                      └── FieldValue::updateOrCreate(...) - race-safe SQLSTATE-23000 retry

On the read side, $entry->field('handle') resolves through FieldValue::resolvedValue(), which calls $instance->value($this->{$column}): the field type's value() is the post-read transform.

Per-type reference

Each entry below documents the storage contract, settings catalogue, and read-side output. The getRules() line documents what Laravel enforces during validation.

Text: single-line input
Aspect Value
Storage column value_text
getRules() ['string'] (plus required/nullable from layout)
Settings placeholder, max_length, min_length
Read API Returns the raw string
Textarea: multi-line text
Aspect Value
Storage column value_text
getRules() [] (no defaults: accepts anything that survives the layout required/nullable)
Settings placeholder, max_length, rows (default 4)
Read API Returns the raw string
Html: rich text
Aspect Value
Storage column value_text
getRules() ['nullable']
Settings toolbar (basic / full / minimal), allowed_tags
Write transform prepareForStorage() runs Purifier::clean() against config('purifier.adastra'), with the field's allowed_tags overriding HTML.Allowed when set.
Read API Returns the sanitised HTML string.
Number: integer or float
Aspect Value
Storage column value_integer when decimals === 0, else value_float (via HasDecimalStorage trait)
getRules() ['numeric']
Settings min, max, step, decimals (0–10), default
Read API Cast by FieldValue::$casts (integer or float depending on storage column)
Boolean: toggle
Aspect Value
Storage column value_boolean
getRules() ['boolean']
Settings default (toggle), label_on, label_off
Read API cast() returns (bool). FieldValue::$casts also casts the column.
Date: calendar date
Aspect Value
Storage column value_date
getRules() ['date']
Settings min_date, max_date, default (date string or "today"), format
Read API FieldValue::$casts returns Carbon ('value_date' => 'datetime').
Time: time of day
Aspect Value
Storage column value_text (canonical HH:MM or HH:MM:SS)
getRules() ['nullable', 'string', new TimeFormatRule(...)]
Settings include_seconds, min_time, max_time, step_minutes, default (literal value or "now")
Write transform prepareForStorage() validates the pattern, zero-pads the hour, and aligns the seconds component with include_seconds. Throws InvalidArgumentException on malformed input.
Read API value() returns AdAstra\Support\Iso\TimeValue.

Custom validator (TimeFormatRule) honours min_time / max_time.

EmailAddress: email
Aspect Value
Storage column value_text
getRules() []
Settings None
Read API Returns the raw string
Url: URL
Aspect Value
Storage column value_text
getRules() ['string', 'url']
Settings None
Read API Returns the raw string

The Laravel url rule accepts any scheme by default; tighten with 'url:http,https' if needed at the field level.

Telephone: phone number
Aspect Value
Storage column value_text
getRules() ['string']
Settings None
Read API Returns the raw string

Validation is intentionally loose: phone numbers take enough regional formats that "string + library-side cleanup" is the right tradeoff for a content field. There is no format/shape validation beyond string.

ColorPicker: color value
Aspect Value
Storage column value_text
getRules() []
Settings format (hex / rgb / hsl), alpha (toggle), presets (key/value swatches)
Read API Returns the raw string
Relationship: entries M2M
Aspect Value
Storage isRelational() === true: writes to entry_relationships(entry_id, related_entry_id, field_id, sort_order), not field_values
getRules() ['array']
Settings entry_groups[] (handles), entry_types[] (handles), limit
Read API Entry::field() resolves to Collection<Entry> ordered by sort_order.
FileUpload: multi-media via upload + picker
Aspect Value
Storage column value_json (int[] of Media IDs)
Marker Implements AdAstra\Contracts\SyncsToMediables: FieldValueObserver mirrors the array into the mediables pivot table on save
getRules() [] (library-level upload rules apply at upload time)
Settings library (select_multiple of Library IDs), allowed_types (per-field MIME override), min, max
Read API value() returns Collection<Media> sorted by stored array index
Media: same storage as FileUpload, different UX
Aspect Value
Storage column value_json (int[] of Media IDs)
Marker Also implements SyncsToMediables
getRules() []
Settings libraries (required, multi-select), min, max
Read API value() returns Collection<Media> sorted by stored array index
Render Passes the matching media.picker.index URL into the partial; the picker chip strip lazy-loads via JSON

FileUpload and Media share identical storage and observer integration. The split exists so a FileUpload field can also accept inline uploads from the form, while Media is a pure picker chip strip backed by an existing library. They are separate seeded field_types rows.

Users: user picker
Aspect Value
Storage column value_json (int[] of User IDs)
getRules() ['nullable', 'array']
Settings roles[] (restrict to users with these role IDs), limit, display (dropdown / checkboxes / tokens)
Read API value() returns Collection<User> with [id, name, email] only: never exposes password, tokens, remember_token
Select: single-choice dropdown
Aspect Value
Storage column value_text
getRules() ['nullable', 'string']
Settings options (key/value, required), placeholder, default, strict_options (toggle)
Read API Returns the raw string key
Trait ValidatesAgainstOptions
MultiSelect: multi-choice
Aspect Value
Storage column value_json (string[] of keys)
getRules() ['nullable', 'array']
Settings options (required), min, max, display (checkboxes / multiselect), strict_options
Read API cast() returns string[]
Trait ValidatesAgainstOptions

Same dead-validate() caveat as Select. min/max are unenforced via Laravel rules.

RadioGroup: single-choice radio
Aspect Value
Storage column value_text
getRules() ['nullable', 'string']
Settings options (required), default, layout (stacked / inline), strict_options
Read API Returns the raw string key
Trait ValidatesAgainstOptions

Same dead-validate() caveat as Select.

Slider: bounded numeric range
Aspect Value
Storage column value_integer when decimals === 0, else value_float (via HasDecimalStorage)
getRules() [] (no default rules)
Settings min (required, default 0), max (required, default 100), step (default 1), suffix, decimals, default
Read API Cast by FieldValue::$casts
Country: ISO 3166-1 country code
Aspect Value
Storage column value_text (uppercase ISO 3166-1 alpha-2)
getRules() ['nullable', 'string', new CountryCodeRule($allowed)]
Settings allowed_countries[], default, placeholder
Write transform prepareForStorage() uppercases the value
Read API value() returns ['code' => 'US', 'name' => 'United States'] (or null)

CountryCodeRule enforces both validity (every ISO 3166-1 country) and the optional allowed_countries allowlist. Active validation.

StateProvince: ISO 3166-2 subdivision
Aspect Value
Storage column value_text (typically US-CA-style code)
getRules() ['nullable', 'string', new SubdivisionCodeRule($country, $allowFreetextFallback)]
Settings country (required, default 'US'), default, placeholder, allow_freetext_fallback (toggle, default true)
Read API value() returns ['code', 'name', 'country']. Falls back to code when no subdivision data exists for the country.

Note. The field is single-country per instance. To support country-conditional dropdowns the entry must declare a Country field separately and the JS resolves the pair at render time. Not shipped today; document if the requirement comes up.

Money: currency-typed monetary value

See Money field: design notes below.

StructuredRows: repeatable rows of typed columns
Aspect Value
Storage column value_json (array of row objects keyed by column handle)
getRules() ['nullable', 'array']
Settings columns[] (handle/label/type triples: declared via the structured_rows_columns settings widget), min_rows, max_rows, add_label
Read API cast() returns the raw array; render() fills missing column keys with null so the template never hits undefined indices

Money field: design notes

The Money field deserves a focused note because the storage convention is invisible at the column level and the design is deliberately single-currency-per-field-instance.

Storage contract.

Write-path guard.

prepareForStorage() rejects values with more fractional digits than the configured currency allows. 19.999 against a USD field throws InvalidArgumentException rather than silently rounding. This is the behaviour the field-type contract promises: "no implicit rounding."

Read API.

$entry->field('price');  // Money\Money instance (NOT the raw integer)
$entry->field('price')->getAmount();    // '1999' (string, minor units)
$entry->field('price')->getCurrency();  // Money\Currency('USD')

Money\Money provides precision-safe arithmetic (add()/subtract()/multiply()) and formatting helpers via moneyphp/moneyphp's built-in formatters. Never do raw integer math on the underlying column outside this API: the minor-unit scale is currency-dependent.

Settings.

Setting Purpose
currency Required. ISO 4217 code. Sets minor-unit precision + parser.
min Optional. Major-unit decimal string. Enforced by MoneyRangeRule.
max Optional. Major-unit decimal string. Same rule.
default Optional. Pre-filled value, major-unit decimal string.

Design choice: single-currency per field instance.

The currency lives in field settings, not next to the value. A single Field row is therefore single-currency: price cannot be USD on one entry and EUR on another. The two intended patterns when multi-currency behaviour is actually needed:

  1. Per-currency field handles: declare price_usd, price_eur, price_gbp as separate Money fields with different currency settings.
  2. A future "Currency" field type: declare a currency-code field alongside the money field and resolve the pair at render time. Not shipped today; flag if the requirement comes up.

Raw-column awareness.

Any custom report, admin SQL query, or data export that reads field_values.value_integer directly must look up the field's currency setting to interpret the scale. The model-level API ($entry->field('price')) hides this; the raw column does not.

// packages/core/src/Field/Types/Toggle.php
namespace AdAstra\Field\Types;

use AdAstra\Field\AbstractField;

class Toggle extends AbstractField
{
    protected string $handle = 'toggle';
    protected string $name = 'Toggle';
    protected array $rules = ['boolean'];

    public function storageColumn(): string { return 'value_boolean'; }
    public function cast(mixed $value): bool { return (bool) $value; }
}

Register it in a seeder:

use AdAstra\Models\Field\Type as FieldType;

FieldType::firstOrCreate(
    ['object' => \AdAstra\Field\Types\Toggle::class],
    ['name' => 'Toggle']
);

Field Groups and Fields

FieldGroups are reusable bundles of fields attached to whatever uses the HasFieldGroups trait: EntryGroup, CategoryGroup, UserSchema, and Media\Library. The attachment uses a polymorphic pivot (field_groupables) with a group_id foreign-key column.

Field columns: field_type_id (nullable FK), name, handle (globally unique), label, instructions, settings (JSON), hidden (boolean).

Creating a Field Group with Fields

use AdAstra\Models\Field;
use AdAstra\Models\Field\Group as FieldGroup;
use AdAstra\Models\Field\Type as FieldType;

$textType = FieldType::where('object', \AdAstra\Field\Types\Text::class)->firstOrFail();

$group = FieldGroup::firstOrCreate(
    ['handle' => 'product-details'],
    ['name' => 'Product Details', 'description' => 'Core product information.']
);

foreach ([
    ['handle' => 'price', 'name' => 'Price', 'label' => 'Price'],
    ['handle' => 'sku', 'name' => 'SKU', 'label' => 'SKU Number'],
] as $def) {
    $field = Field::firstOrCreate(
        ['handle' => $def['handle']],
        array_merge($def, ['field_type_id' => $textType->id])
    );
    $group->fields()->syncWithoutDetaching([$field->id]);
}

Field Layouts

A FieldLayout organises fields into named tabs. FieldLayout::tabs() is ordered by sort_order; Tab::elements() likewise.

Building a Layout Programmatically

use AdAstra\Models\Field;
use AdAstra\Models\FieldLayout;
use AdAstra\Models\FieldLayout\Tab;
use AdAstra\Models\FieldLayout\TabElement;

$layout = FieldLayout::create(['name' => 'Article Layout']);
$contentTab = Tab::create(['field_layout_id' => $layout->id, 'name' => 'Content', 'sort_order' => 1]);

foreach (['body', 'excerpt'] as $order => $handle) {
    TabElement::create([
        'field_layout_tab_id' => $contentTab->id,
        'field_id' => Field::where('handle', $handle)->value('id'),
        'required' => $handle === 'body',
        'sort_order' => $order + 1,
    ]);
}

$seoTab = Tab::create(['field_layout_id' => $layout->id, 'name' => 'SEO', 'sort_order' => 2]);

foreach (['meta_title', 'meta_description'] as $order => $handle) {
    TabElement::create([
        'field_layout_tab_id' => $seoTab->id,
        'field_id' => Field::where('handle', $handle)->value('id'),
        'required' => false,
        'sort_order' => $order + 1,
    ]);
}

Getting All Fields from a Layout

$layout->fields(); // Collection<Field>, flattened from all tabs in sort order

FieldLayout::fields() calls loadMissing('tabs.elements.field'): N+1-safe.

Field Uniqueness Constraint

A field may only be assigned once per layout: not once per tab. The field_layout_tab_elements table enforces uniqueness at the tab level (field_layout_tab_id + field_id), but the admin UI enforces it at the layout level: the Available Fields panel for a tab excludes any field already assigned to any other tab within the same layout.

Known gap: moving a field between tabs: There is no single-step "move" operation. To reassign a field from Tab A to Tab B, remove it from Tab A (save), then add it to Tab B (save). A dedicated move UI has not been implemented.


Status Groups and Statuses

Creating a Status Group

use AdAstra\Models\Status;
use AdAstra\Models\StatusGroup;

$group = StatusGroup::create([
    'name' => 'Review Workflow',
    'handle' => 'review',
    'sort_order' => 2,
]);

$statuses = [
    [
        'name' => 'Pending Review',
        'handle' => 'pending',
        'color' => '#F59E0B',
        'is_default' => true,
        'is_public' => false,
        'sort_order' => 1,
    ],
    [
        'name' => 'Approved',
        'handle' => 'approved',
        'color' => '#10B981',
        'is_default' => false,
        'is_public' => true,
        'sort_order' => 2,
    ],
    [
        'name' => 'Rejected',
        'handle' => 'rejected',
        'color' => '#EF4444',
        'is_default' => false,
        'is_public' => false,
        'sort_order' => 3,
    ],
];

foreach ($statuses as $s) {
    Status::create(array_merge($s, ['status_group_id' => $group->id]));
}

How an Entry Stores its Status

The status is denormalised across three columns maintained together by EntryRepository::applyStatus():

Column Notes
status_id nullable FK to statuses.id, nullOnDelete
status_handle indexed string for fast lookups
status_is_public indexed boolean

Entry::scopePublished() filters on status_is_public = true AND published_at IS NOT NULL AND published_at <= now().

StatusObserver: keeping status_is_public consistent

// packages/core/src/Observers/StatusObserver.php - registered in AppServiceProvider::boot()
public function updating(Status $status): void
{
    if ($status->isDirty('is_public')) {
        Entry::where('status_id', $status->id)
            ->update(['status_is_public' => $status->is_public]);
    }
}

Category Groups and Categories

Schema notes: category_groups has handle (unique), field_layout_id (nullable), sort_order. categories has group_id (FK, cascade delete), parent_id (nullable, self-FK), name, handle, sort_order. Unique on (group_id, handle).

Creating a Category Group and Categories

use AdAstra\Models\Category;
use AdAstra\Models\Category\Group as CategoryGroup;

$group = CategoryGroup::firstOrCreate(
    ['handle' => 'regions'],
    ['name' => 'Regions', 'sort_order' => 1]
);

$europe = Category::create([
    'group_id' => $group->id,
    'name' => 'Europe',
    'handle' => 'europe',
    'sort_order' => 1,
]);

Category::create([
    'group_id' => $group->id,
    'parent_id' => $europe->id,
    'name' => 'France',
    'handle' => 'france',
    'sort_order' => 1,
]);

Fetching Categories

// Root categories with full recursive tree (default depth = 10)
$group->rootCategories()->with('childrenRecursive')->get();

// Scoped query
Category::inGroup($group)->roots()->with('childrenRecursive')->get();

Custom Field Groups on Category Groups

CategoryGroup supports two parallel field systems. FieldGroups (via HasFieldGroups / field_groupables) define which fields are available to categories within the group. FieldLayout (via HasFieldLayout / field_layout_id) controls how those fields are displayed in the admin UI.

use AdAstra\Models\Category;
use AdAstra\Models\Category\Group as CategoryGroup;
use AdAstra\Models\Field;
use AdAstra\Models\Field\Group as FieldGroup;
use AdAstra\Models\Field\Type as FieldType;
use AdAstra\Models\FieldLayout;
use AdAstra\Models\FieldLayout\Tab;
use AdAstra\Models\FieldLayout\TabElement;
use AdAstra\Services\CategoryService;

$textType = FieldType::where('object', \AdAstra\Field\Types\Text::class)->firstOrFail();

// 1. Create fields
$description = Field::firstOrCreate(
    ['handle' => 'cat_description'],
    ['name' => 'Description', 'label' => 'Description', 'field_type_id' => $textType->id]
);
$imageUrl = Field::firstOrCreate(
    ['handle' => 'cat_image_url'],
    ['name' => 'Image URL', 'label' => 'Image URL', 'field_type_id' => $textType->id]
);

// 2. FieldGroup
$fieldGroup = FieldGroup::firstOrCreate(['handle' => 'category-extras'], ['name' => 'Category Extras']);
$fieldGroup->fields()->syncWithoutDetaching([$description->id, $imageUrl->id]);

// 3. Layout
$layout = FieldLayout::create(['name' => 'Topic Category Layout']);
$tab = Tab::create(['field_layout_id' => $layout->id, 'name' => 'Details', 'sort_order' => 1]);
foreach ([$description, $imageUrl] as $i => $field) {
    TabElement::create([
        'field_layout_tab_id' => $tab->id,
        'field_id' => $field->id,
        'sort_order' => $i + 1,
    ]);
}

// 4. Attach to CategoryGroup
$categoryGroup = CategoryGroup::where('handle', 'topics')->firstOrFail();
$categoryGroup->field_layout_id = $layout->id;
$categoryGroup->save();
$categoryGroup->fieldGroups()->syncWithoutDetaching([$fieldGroup->id]);

// 5. Write fields via CategoryService
$categoryService = app(CategoryService::class);

$category = $categoryService->create($categoryGroup, [
    'name' => 'PHP',
    'handle' => 'php',
    'fields' => [
        'cat_description' => 'Articles about the PHP language.',
        'cat_image_url' => 'https://example.com/php.png',
    ],
]);

// 6. Update
$categoryService->update($category, [
    'fields' => ['cat_description' => 'Updated description.'],
]);

// 7. Read
$category = Category::with('fieldValues.field.fieldType')->where('handle', 'php')->firstOrFail();
echo $category->field('cat_description'); // 'Updated description.'
echo $category->field('cat_image_url');   // 'https://example.com/php.png'

Entry Groups and Entry Types

An EntryGroup is the section/channel (e.g. "Blog", "Products") tying together a FieldLayout, a StatusGroup, polymorphic CategoryGroups, and polymorphic FieldGroups.

An EntryType row maps a group-scoped handle to PHP behaviour through the EntryBehavior registry:

Column Description
entry_group_id FK to entry_groups, nullable, nullOnDelete
entry_behavior_id FK to entry_behaviors, nullable, nullOnDelete
field_layout_id Optional override layout for this type
name, handle (entry_group_id, handle) unique
default_template Optional default template for SiteRouter
default_schema_type Default schema.org type (SEO; reserved)
has_entry_tree, max_depth, allowed_parent_types Tree config
sort_order Display order within the group

Runtime resolution (corrected)

EntryService::create($handle, ...)
  └── EntryTypeRegistry::resolveByHandle($handle)
        ├── Fetch EntryType row (with('entryGroup', 'entryBehavior', 'fieldLayout…'))
        └── Instantiate:
              ├── If entryBehavior IS NULL → GeneralEntryType (fallback)
              └── Else → EntryBehavior::instance($record):
                    ├── $fqcn = Relation::getMorphedModel($behavior->class)
                    ├── Throws RuntimeException if morph key not registered
                    ├── Throws RuntimeException if class does not exist
                    ├── Throws RuntimeException if class does not extend AbstractEntryType
                    └── return new $fqcn($record)

EntryTypeRegistry::resolveByHandle() resolves by handle only: it does not filter by group. Keep entry type handles globally unique when using Content::create('type_handle', ...) unless the creation API is extended to accept group context.

Adding a new EntryType

  1. Write a PHP class extending AbstractEntryType in packages/core/src/EntryTypes/.
  2. Register a morph alias in AppServiceProvider::boot()'s morph map under the behavior.* prefix.
  3. Insert a row into entry_behaviors (the EntryBehaviorSeeder is the canonical example): class is the morph alias, not the FQCN.
  4. Insert (or update) the entry_types row with entry_behavior_id pointing at the new behaviour.
  5. Run php artisan adastra:validate-class-references (or the full adastra:doctor) to confirm the morph alias resolves to a real class extending AbstractEntryType.

Seeded Entry Groups and Types

The seeders create one entry type per seeded entry group. The handles are currently globally unique, which is important because Content::create() and EntryTypeRegistry::resolveByHandle() resolve by type handle alone.

EntryGroup handle EntryType handle Name Behaviour handle Resolves to Status group Tree routing
blog blog_post Blog Post blog-post BlogPostEntryType publication No
products product Product product ProductEntryType product-status No
events event Event event EventEntryType publication No
news news_article News Article news-article NewsArticleEntryType publication No
pages page Page page PageEntryType publication Yes
jobs job_listing Job Listing job-listing JobListingEntryType job-status No
podcast podcast_episode Podcast Episode podcast-episode PodcastEpisodeEntryType publication No
portfolio portfolio_item Portfolio Item portfolio-item PortfolioItemEntryType publication Yes
videos video Video video VideoEntryType publication Yes
recipes recipe Recipe recipe RecipeEntryType publication Yes
general general General general GeneralEntryType publication No

Mind the dialect: EntryType handles tend to use underscores (blog_post), behaviour handles use kebab-case (blog-post). The behavior.* morph alias keys (in entry_behaviors.class) also use kebab.

pages, portfolio, videos, and recipes also seed default_template = 'entries.page'. If an Entry Tree node has its own template, that node template wins over the entry type default.

Available Entry Type Classes

Entry type classes live in packages/core/src/EntryTypes/ and extend AbstractEntryType. They are behavior objects for lifecycle hooks and validation; field schema still comes from the entry group's layout and optional type-level layout.

Class Current behavior
GeneralEntryType Default/fallback type; stamps published_at on create, and when transitioning to published on update if no publish date exists
BlogPostEntryType Stamps published_at when created as published; computes reading_time from fields.body at roughly 200 words/minute
EventEntryType Stamps published_at; validates end_date >= start_date when both can be resolved
JobListingEntryType Stamps published_at; clears publication for expired/closed; auto-expires after closing_date; requires application URL or email before publishing
NewsArticleEntryType Stamps published_at on publish; requires source when source_url is present
PageEntryType Stamps published_at on create
PodcastEpisodeEntryType Auto-assigns episode_number under a group-row lock; stamps published_at; validates positive integer episode_duration on update
PortfolioItemEntryType Stamps published_at on create
ProductEntryType Validates price/sale-price rules; requires SKU before publishing; sets status to out-of-stock when stock reaches zero
RecipeEntryType Stamps published_at; computes total_time from prep_time + cook_time
VideoEntryType Stamps published_at; requires platform_id or video_url before publishing

AbstractEntryType provides:

public function getRecord(): EntryTypeRecord;
public function getName(): string;
public function getHandle(): string;
public function getEntryGroup(): EntryGroup;
public function validate(array $data, ?Entry $entry = null): array;

It also provides existingFieldValue(?Entry $entry, string $handle): mixed for safe validation/update logic that needs to inspect an existing field value.

Registry Resolution and Admin Constraints

EntryTypeRegistry has two resolution paths:

Method Used by Notes
resolveByHandle() EntryService::create() Looks up the first entry_types.handle match globally and caches by handle
resolveByRecord() EntryService::update() Instantiates from the entry's existing EntryType row and caches by ID

entry_types.entry_behavior_id is nullable. When a row's behaviour FK is null (or the linked behaviour's morph key has gone missing from AppServiceProvider::boot()), EntryTypeRegistry::instantiate() returns GeneralEntryType as a fallback. When the linked behaviour resolves but the resulting PHP class either doesn't exist or doesn't extend AbstractEntryType, EntryBehavior::instance() throws RuntimeException: that's a deploy-time failure, not a silent fallback.

StoreEntryTypeRequest and EditEntryTypeRequest validate entry_behavior_id as ['nullable', 'integer', 'exists:entry_behaviors,id']. The standalone ExtendsClass validation rule still lives in packages/core/src/Rules/ExtendsClass.php but is not wired into the EntryType requests: the morph-alias indirection means the class linkage is now a deploy-time invariant (adastra:validate-class-references), not a form-time one.

In practice:

Field Layering: Group and Type Fields

// From EntryRepository::resolveLayoutFields()
$groupFields = $entry->entryGroup->fieldLayout?->fields() ?? collect();
$typeFields = $entry->entryType->fieldLayout?->fields() ?? collect();

return $typeFields->merge($groupFields)->unique('id'); // type-level fields take precedence

Lifecycle Hook Signatures

public function beforeCreate(array $data): array { return $data; }
public function afterCreate(Entry $entry, array $data): void {}

public function beforeUpdate(Entry $entry, array $data): array { return $data; }
public function afterUpdate(Entry $entry, array $data): void {}

beforeCreate() runs inside the DB transaction; afterCreate() runs outside so its side-effects cannot be rolled back.

Creating an Entry Group

use AdAstra\Models\EntryGroup;
use AdAstra\Models\StatusGroup;

$statusGroup = StatusGroup::where('handle', 'publication')->firstOrFail();

$group = EntryGroup::create([
    'name' => 'News Articles',
    'handle' => 'news',
    'description' => 'News and press releases.',
    'field_layout_id' => $layout->id,
    'status_group_id' => $statusGroup->id,
    'sort_order' => 3,
]);

$group->fieldGroups()->syncWithoutDetaching([$fieldGroup->id]);
$group->categoryGroups()->syncWithoutDetaching([$categoryGroup->id]);

status_group_id is nullable in the schema but EntryRepository throws RuntimeException if it is missing during status resolution: treat as required.

Creating an Entry Type Class

// packages/core/src/EntryTypes/NewsArticleEntryType.php
namespace AdAstra\EntryTypes;

use AdAstra\Models\Entry;

class NewsArticleEntryType extends AbstractEntryType
{
    public function beforeCreate(array $data): array
    {
        $data['status'] = $data['status'] ?? 'draft';
        return $data;
    }

    public function afterCreate(Entry $entry, array $data): void
    {
        // SendReviewNotification::dispatch($entry);
    }

    public function beforeUpdate(Entry $entry, array $data): array
    {
        if ($entry->status_handle === 'published' && ($data['status'] ?? null) === 'draft') {
            unset($data['status']);
        }
        return $data;
    }
}

Registering the Entry Type in the Database

use AdAstra\Models\EntryBehavior;
use AdAstra\Models\EntryGroup;
use AdAstra\Models\EntryType;

$group = EntryGroup::where('handle', 'news')->firstOrFail();
$behavior = EntryBehavior::where('handle', 'news-article')->firstOrFail();
// EntryBehavior row was created by EntryBehaviorSeeder with
// class = 'behavior.news-article' (a morph alias registered in
// AppServiceProvider::boot()).

EntryType::firstOrCreate(
    ['entry_group_id' => $group->id, 'handle' => 'news_article'],
    [
        'name' => 'News Article',
        'entry_behavior_id' => $behavior->id,
        'sort_order' => 1,
    ]
);

Adding a New Entry Type End-to-End

The following walkthrough adds a "Recipes" section with two types: Standard Recipe and Video Recipe.

1. Create fields and a FieldGroup

use AdAstra\Models\Field;
use AdAstra\Models\Field\Group as FieldGroup;
use AdAstra\Models\Field\Type as FieldType;

$text = FieldType::where('object', \AdAstra\Field\Types\Text::class)->firstOrFail();
$textarea = FieldType::where('object', \AdAstra\Field\Types\Textarea::class)->firstOrFail();
$number = FieldType::where('object', \AdAstra\Field\Types\Number::class)->firstOrFail();

$fieldDefs = [
    [
        'handle' => 'ingredients',
        'name' => 'Ingredients',
        'label' => 'Ingredients',
        'type' => $textarea,
    ],
    [
        'handle' => 'instructions',
        'name' => 'Instructions',
        'label' => 'Instructions',
        'type' => $textarea,
    ],
    [
        'handle' => 'prep_time_mins',
        'name' => 'Prep Time',
        'label' => 'Prep Time (min)',
        'type' => $number,
    ],
    [
        'handle' => 'servings',
        'name' => 'Servings',
        'label' => 'Servings',
        'type' => $number,
    ],
    [
        'handle' => 'video_url',
        'name' => 'Video URL',
        'label' => 'Video URL',
        'type' => $text,
    ],
];

foreach ($fieldDefs as $def) {
    Field::firstOrCreate(
        ['handle' => $def['handle']],
        [
            'name' => $def['name'],
            'label' => $def['label'],
            'field_type_id' => $def['type']->id,
        ]
    );
}

$recipeGroup = FieldGroup::firstOrCreate(['handle' => 'recipe-core'], ['name' => 'Recipe Core Fields']);
$coreIds = Field::whereIn('handle', ['ingredients', 'instructions', 'prep_time_mins', 'servings'])->pluck('id');
$recipeGroup->fields()->syncWithoutDetaching($coreIds->all());

2. Create FieldLayouts

use AdAstra\Models\FieldLayout;
use AdAstra\Models\FieldLayout\Tab;
use AdAstra\Models\FieldLayout\TabElement;

// Shared group layout
$groupLayout = FieldLayout::create(['name' => 'Recipe Group Layout']);
$recipeTab = Tab::create(['field_layout_id' => $groupLayout->id, 'name' => 'Recipe', 'sort_order' => 1]);

foreach (Field::whereIn('handle', ['ingredients', 'instructions', 'prep_time_mins', 'servings'])->get() as $i => $field) {
    TabElement::create([
        'field_layout_tab_id' => $recipeTab->id,
        'field_id' => $field->id,
        'required' => in_array($field->handle, ['ingredients', 'instructions']),
        'sort_order' => $i + 1,
    ]);
}

// Video-specific type layout
$videoLayout = FieldLayout::create(['name' => 'Video Recipe Layout']);
$videoTab = Tab::create(['field_layout_id' => $videoLayout->id, 'name' => 'Video', 'sort_order' => 1]);
TabElement::create([
    'field_layout_tab_id' => $videoTab->id,
    'field_id' => Field::where('handle', 'video_url')->value('id'),
    'required' => true,
    'sort_order' => 1,
]);

3. Create the EntryGroup

use AdAstra\Models\EntryGroup;
use AdAstra\Models\StatusGroup;

$statusGroup = StatusGroup::where('handle', 'publication')->firstOrFail();

$entryGroup = EntryGroup::create([
    'name' => 'Recipes',
    'handle' => 'recipes',
    'description' => 'Step-by-step cooking guides.',
    'field_layout_id' => $groupLayout->id,
    'status_group_id' => $statusGroup->id,
    'sort_order' => 10,
]);

$entryGroup->fieldGroups()->syncWithoutDetaching([$recipeGroup->id]);

4. Write the EntryType PHP classes

// packages/core/src/EntryTypes/RecipeEntryType.php
namespace AdAstra\EntryTypes;

use AdAstra\Models\Entry;

class RecipeEntryType extends AbstractEntryType
{
    public function beforeCreate(array $data): array
    {
        $data['status'] = $data['status'] ?? 'draft';
        return $data;
    }
}

// packages/core/src/EntryTypes/VideoRecipeEntryType.php
class VideoRecipeEntryType extends RecipeEntryType
{
    public function afterCreate(Entry $entry, array $data): void
    {
        // ProcessVideoRecipe::dispatch($entry);
    }
}

5. Register the EntryType rows

use AdAstra\Models\EntryBehavior;
use AdAstra\Models\EntryType;
use Illuminate\Database\Eloquent\Relations\Relation;

// 5a. Register the morph alias once (in AppServiceProvider::boot())
Relation::morphMap([
    // existing entries…
    'behavior.recipe' => \AdAstra\EntryTypes\RecipeEntryType::class,
    'behavior.video-recipe' => \AdAstra\EntryTypes\VideoRecipeEntryType::class,
]);

// 5b. Insert the EntryBehavior rows
$standard = EntryBehavior::firstOrCreate(
    ['handle' => 'recipe'],
    ['name' => 'Recipe', 'class' => 'behavior.recipe']
);
$video = EntryBehavior::firstOrCreate(
    ['handle' => 'video-recipe'],
    ['name' => 'Video Recipe', 'class' => 'behavior.video-recipe']
);

// 5c. Bind EntryType rows to those behaviours
EntryType::firstOrCreate(
    ['entry_group_id' => $entryGroup->id, 'handle' => 'recipe'],
    ['name' => 'Standard Recipe', 'entry_behavior_id' => $standard->id, 'sort_order' => 1]
);

EntryType::firstOrCreate(
    ['entry_group_id' => $entryGroup->id, 'handle' => 'video_recipe'],
    [
        'name' => 'Video Recipe',
        'entry_behavior_id' => $video->id,
        'field_layout_id' => $videoLayout->id,
        'sort_order' => 2,
    ]
);

6. Validate and create entries

php artisan adastra:validate-class-references
use AdAstra\Facades\Content;

$recipe = Content::create('recipe', [
    'title' => 'Classic Carbonara',
    'status' => 'published',
    'fields' => [
        'ingredients' => "200g spaghetti\n2 eggs\n100g pancetta",
        'instructions' => "1. Boil pasta...\n2. Fry pancetta...",
        'prep_time_mins' => 15,
        'servings' => 2,
    ],
]);

$videoRecipe = Content::create('video_recipe', [
    'title' => 'Carbonara in 60 Seconds',
    'status' => 'published',
    'fields' => [
        'ingredients' => "200g spaghetti\n2 eggs",
        'instructions' => 'Watch the video.',
        'prep_time_mins' => 5,
        'servings' => 2,
        'video_url' => 'https://youtube.com/watch?v=example',
    ],
]);

// Query
$allRecipes = Content::query()->inGroup('recipes')->published()->get();
$videos = Content::query()->ofType('video_recipe')->published()->latest()->paginate(12);

echo $recipe->field('ingredients');
echo $videoRecipe->field('video_url');

Creating and Updating Entries

All entry creation goes through one of two functionally identical facades:

Creating an Entry

use AdAstra\Facades\Content;
use AdAstra\Models\Category;
use AdAstra\Models\User;

$author = User::find(1);
$category = Category::where('handle', 'france')->firstOrFail();

$entry = Content::create('news_article', [
    'title' => 'Election Results 2026',
    'published_at' => now(),
    'status' => 'published',
    'authors' => [$author->id], // ordered M2M - sort_order = array key
    'categories' => [$category->id],
    'fields' => [
        'body' => 'Full article text...',
        'excerpt' => 'Short summary.',
        'meta_title' => 'Election Results 2026 | News',
        'meta_description' => 'Coverage of the 2026 election.',
    ],
]);

echo $entry->id;     // persisted Entry model
echo $entry->handle; // auto-generated via Str::slug($title) if not provided

Updating an Entry

use AdAstra\Facades\Content;

$updated = Content::update($entry, [
    'title' => 'Updated Title',
    'status' => 'approved',
    'fields' => ['excerpt' => 'Revised summary.'],
]);

Direct $entry->update([...]) writes core attributes only: it does not sync authors, categories, or custom fields.

Using the Relationship Field

Relationship fields store related entry IDs in entry_relationships, not in field_values. Pass related IDs inside the fields key as an array: array order is preserved as sort_order.

Writing

$relatedA = Content::query()->inGroup('products')->where('handle', 'widget-a')->firstOrFail()->id;
$relatedB = Content::query()->inGroup('products')->where('handle', 'widget-b')->firstOrFail()->id;

// On create
$post = Content::create('blog_post', [
    'title' => 'My Post',
    'handle' => 'my-post',
    'fields' => [
        'related_products' => [$relatedA, $relatedB],
    ],
]);

// On update - replaces all existing pivots for that field
Content::update($post, [
    'fields' => [
        'related_products' => [$relatedB], // removes $relatedA
    ],
]);

Reading

$post = Content::query()->inGroup('blog')->where('handle', 'my-post')->firstOrFail();

$related = $post->field('related_products'); // Collection<Entry>

foreach ($related as $product) {
    echo $product->title;
    echo $product->field('price');
}

$entry->field('handle') returns a single value for scalar fields and a Collection<Entry> for relationship fields.

Recursion with cycle detection

use AdAstra\Facades\Entries;

$tree = Entries::loadRelatedRecursive($post, 'related_products', maxDepth: 3);
// Returns a flat Collection<Entry>, depth-limited and cycle-safe.

Raw pivot access

$post->entryRelationships
    ->where('field.handle', 'related_products')
    ->sortBy('sort_order')
    ->each(fn ($pivot) => echo $pivot->relatedEntry->title);

Querying Entries

Use Content::query() (or Entries::query()) for a fluent query builder backed by AdAstra\Builders\EntryQueryBuilder. All terminal methods apply the full eager-load automatically.

inGroup() vs ofType(): inGroup('blog') matches the EntryGroup handle. ofType('blog_post') matches the EntryType handle. Mixing them silently returns no results.

use AdAstra\Facades\Content;
use AdAstra\Models\Category;

// Published blog posts, newest first
$posts = Content::query()->inGroup('blog')->published()->latest()->get();

// By entry type
$articles = Content::query()->ofType('news_article')->withStatus('approved')->paginate(20);

// By author
$myPosts = Content::query()->inGroup('blog')->withAuthor(Auth::id())->latest()->get();

// By category
$technology = Category::where('handle', 'technology')->firstOrFail();
$techPosts = Content::query()
    ->inGroup('blog')
    ->withCategory($technology->id)
    ->published()
    ->orderBy('published_at', 'desc')
    ->paginate(10);

// By ID
$entry = Content::get(42);   // throws ModelNotFoundException if missing
$entry = Content::find(42);  // returns null if missing

// By handle within a group
$entry = Entries::query()->inGroup('blog')->where('handle', 'my-post')->firstOrFail();

Full EntryQueryBuilder surface

Method Notes
inGroup($group) string|int|EntryGroup
ofType($type) string|int|EntryType
published() status_is_public = true AND published_at <= now()
withStatus($handle) matches status_handle
withAuthor(int $userId) whereHas('authors', users.id)
withCategory(int $categoryId) whereHas('categories', categories.id)
where($column, $op, $value = null) passthrough to Eloquent Builder
orderBy($column, $direction = 'asc') passthrough
latest() orderBy('created_at', 'desc')
get() / paginate(int) / first() / firstOrFail() terminal; always eager-load
count() does not apply eager loads

Reading Field Values

echo $entry->field('body');           // string
echo $entry->field('price');          // int or float
$entry->field('event_date')?->format('Y-m-d'); // Carbon from Date field

$related = $entry->field('related_entries'); // Collection<Entry>
foreach ($related as $rel) {
    echo $rel->title;
}

Accessing Entry Authors

Relationship Type Description
creator BelongsTo User User who created the record (created_by_user_id)
authors BelongsToMany EntryAuthor Eligible byline authors, ordered by pivot sort_order from entry_author_entry

Entry::authors() returns EntryAuthor models, not User models directly. Each EntryAuthor has a user_id FK, a nullable display_name, and carries status. Access the underlying user through the user relation:

$post = Content::query()->inGroup('blog')->where('handle', 'my-post')->firstOrFail();

// Creator (the user who hit "Save" - always a User)
echo $post->creator->name;

// Authors (EntryAuthor eligibility records, eager-loaded automatically)
foreach ($post->authors as $author) {
    echo $author->display_name;      // falls back to user->name if null
    echo $author->user_id;           // FK to users table
    echo $author->user->email;       // access the related User
    echo $author->pivot->sort_order; // ordering from entry_author_entry pivot
}

// Primary author (lowest sort_order)
$primary = $post->authors->first();
echo $primary?->display_name;

authors is always eager-loaded by EntryQueryBuilder's terminal methods (get(), first(), firstOrFail(), paginate()). For entries fetched by other means, eager-load explicitly:

$entry->load('authors.user');

Filtering by author uses withAuthor(int $userId) on the query builder. It accepts the plain user_id (not the entry_author_id):

$posts = Content::query()
    ->inGroup('blog')
    ->withAuthor($user->id)
    ->published()
    ->get();

Accessing Entry Categories via the Content Facade

Entries carry a categories() morphToMany via the HasCategories trait. Categories are always eager-loaded by EntryQueryBuilder's terminal methods (get(), first(), firstOrFail(), paginate()). No additional with('categories') call is needed on the query builder.

Reading categories on a result set

use AdAstra\Facades\Content;

$entries = Content::query()->inGroup('blog')->published()->get();
// categories are already available - no extra with() needed

foreach ($entries as $entry) {
    foreach ($entry->categories as $category) {
        echo $category->name;
        echo $category->handle;
    }
}

Loading a category's group on already-fetched entries

categories.group is not in the default eager-load. Load it explicitly:

$entries = Content::query()->inGroup('blog')->published()->get();
$entries->load('categories.group');

foreach ($entries as $entry) {
    foreach ($entry->categories as $category) {
        echo $category->group->name; // e.g. "Topics"
    }
}

Filtering entries by category

use AdAstra\Models\Category;

$php = Category::where('handle', 'php')->firstOrFail();

$entries = Content::query()
    ->inGroup('blog')
    ->withCategory($php->id)
    ->published()
    ->orderBy('published_at', 'desc')
    ->paginate(10);

Accessing category field values

$entries = Content::query()->inGroup('blog')->get();
$entries->load('categories.fieldValues.field.fieldType');

foreach ($entries as $entry) {
    foreach ($entry->categories as $category) {
        echo $category->field('cat_description');
    }
}

Categories support scalar fields only. Fieldable::field() inspects fieldValues but not entryRelationships. Only Entry::field() adds the relational fallback.


Entry Metrics

Entries can record named daily metrics through EntryMetric rows. Each row is unique by entry, metric name, and recorded date.

use AdAstra\Facades\Entries;

Entries::recordMetric($entry, 'view');
Entries::recordMetric($entry, 'download', value: 3, date: now()->subDay());

$views = $entry->metricTotal('view');
$recentViews = $entry->metricTotal('view', from: now()->subDays(30));

EntryService::recordMetric() increments an existing row when present, or creates a new row for the day. If two requests race to create the same metric row, the losing insert retries as an increment after the unique-constraint failure.

Entry::metricTotal() aggregates in the database and can optionally filter from a given date forward.


Deleting Entries

// FK cascades remove field_values, entry_relationships, entry_author_entry
// (pivot rows), categorizables, and the entry_tree node automatically.
// entry_authors eligibility records are NOT removed - they belong to the user,
// not the entry.
$entry->delete();

// Via the facade - preferred for consistency
\AdAstra\Facades\Entries::delete($entry);

Media Library

The native Media layer is complete and in testing. Media is handled by first-party Laravel models and storage helpers.

Libraries

media_libraries stores admin-defined upload containers:

Column Purpose
name / handle Human name and unique library handle
field_layout_id Optional layout for custom media fields
adapter Storage disk/adapter name used during upload
adapter_settings JSON settings for adapter-specific behavior
allowed_types JSON list of allowed MIME types
max_size Integer size limit used by validation
sort_order Admin ordering

Media\Library uses HasMediaItems for uploads and can own category groups, field groups, and an optional field layout. The seeded avatars library is used by User::avatar() and related avatar helpers.

Uploads

The admin upload path is:

POST /admin/media/libraries/{library_id}/upload
  -> Admin\Media\Library::upload()
  -> AdAstra\Actions\Media\Library\UploadMedia::upload()
  -> AdAstra\Services\MediaStorageService::upload()
  -> Media\Library::addMediaFromUpload()

addMediaFromUpload() validates against the library constraints, stores the physical file on the configured disk, then creates the media row in a transaction. If persistence fails after storage succeeds, the stored file is deleted as compensation.

Attachments and Field Usage

Entry and User use HasMedia for direct attachments through mediables. Direct attachments use field_id = 0; media referenced by a FileUpload field uses the real fields.id.

FileUpload stores ordered media IDs in field_values.value_json. FieldValueObserver keeps the mediables pivot synchronized so field-driven media usage remains queryable.

Media Picker Endpoint

The admin field UIs for FileUpload, Media, and any other field that lets an editor pick existing media call a dedicated JSON endpoint to populate the picker chip strip rather than embedding the full library into the entry form.

Property Value
HTTP method GET
URI /admin/media/picker
Route name media.picker.index
Controller AdAstra\Http\Controllers\Admin\MediaPicker::index
Auth guard auth middleware on the admin route group; access admin from Admin\Controller constructor
Response format JSON

Query parameters:

Param Rules Notes
library_id[] required, array, min:1, integer.* Allowed libraries; unknown IDs are dropped server-side
q nullable, string, max:200 Name search; SQL LIKE wildcards in the input are escaped with \
page nullable, integer, min:1 Paginator page (default 1)
per_page nullable, integer, min:1, max:100 Page size (default 24)

Response shape:

{
  "data": [
    {
      "id": 42,
      "name": "Cover Image",
      "original_name": "cover.jpg",
      "mime_type": "image/jpeg",
      "size": 184320,
      "library_id": 3,
      "library_name": "Editorial",
      "url": "https://...",
      "thumbnail_url": "https://...",
      "is_image": true
    }
  ],
  "meta": {
    "total": 128,
    "current_page": 1,
    "last_page": 6,
    "per_page": 24
  }
}

Thumbnails. For image media, the endpoint kicks a picker transformation (240×240, cover mode) idempotently. Subsequent picker calls re-use the existing transformation record rather than regenerating. Non-image media gets thumbnail_url = null; the field UIs fall back to a file-type icon.

Why a separate endpoint. Embedding a media list inline in every entry form would scale badly once a library has thousands of items. The picker endpoint stays JSON-only so the field's JS can lazy-load and paginate without re-rendering the surrounding form.

Categories, Fields, Transformations, and Cleanup

Media items can be categorized through categorizables and can store custom fields because Media uses Fieldable.

Transformations live in media_transformations and are dispatched through TransformationDriverInterface with Imagick, GD, or null-driver implementations.

Media records are soft-deleted first. PurgeDeletedMedia removes physical files and transformation files after the configured grace period.


Site Routing (Public-Facing URLs)

The public-facing site is served by a catch-all frontend route and a two-driver resolution pipeline. This is server-rendered Laravel view routing, not a client SPA router.

Frontend Catch-All Route

packages/core/routes/web.php registers social login routes first, then sends every remaining frontend URL to SiteController@show:

Route::get('/{uri?}', [SiteController::class, 'show'])
    ->where('uri', '.*')
    ->name('site.show');

SiteController::show() receives the optional URI and delegates to AdAstra\Services\SiteRouting\SiteRouter::render(). SiteRouter calls resolve(), then renders the selected Laravel view:

public function render(?string $uri): View
{
    $result = $this->resolve($uri);

    return view($result->template, $result->data);
}

Drivers are tried in config('site.routing.priority') order. The default is:

['entry_tree', 'template']

The first driver returning a non-null RouteResult wins. If no driver resolves the URI, SiteRouter::resolve() throws NotFoundHttpException.

RouteResult

Route drivers return AdAstra\Services\SiteRouting\RouteResult:

new RouteResult(
    type: 'entry_tree',
    template: 'entries.show',
    data: ['entry' => $entry],
    resource: $node,
);
Property Purpose
type Driver identifier, currently entry_tree or template
template Laravel view name passed to view()
data View data array
resource Matched resource, such as an EntryTree node or view name

Entry Tree Layer

The Entry Tree layer maps entries to explicit public URLs. It is stored in entry_trees and managed through EntryService tree methods.

entry_trees schema:

Column Notes
entry_id Unique FK to entries; each entry can have at most one tree node
parent_id Nullable self-FK; deleting a parent sets direct children to root
handle URL-safe slug segment generated by EntryTree::validatedHandle()
uri Full normalized URI, unique across the tree; home node uses /
depth Root depth is 0; rebuilt when nodes move
sort_order Sibling order
template Optional per-node template override
redirect_url Optional URL to 30x to; takes precedence over template
redirect_status HTTP status used with redirect_url (default 302); column is unsignedSmallInteger
is_home Marks the single home node

Important model helpers:

Helper Behavior
EntryTree::normalizeHandle() Str::slug() for one URL segment
EntryTree::validatedHandle() Slugifies and rejects empty handles
EntryTree::normalizeUri() Trims slashes; empty URI becomes /
$node->url Returns / for home or /{uri} otherwise
root() scope Filters nodes with no parent
byUri($uri) scope Filters by normalized URI

Tree support is enabled per entry type with entry_types.has_entry_tree. max_depth and allowed_parent_types are stored and cast on EntryType, but the current EntryService tree methods only enforce has_entry_tree, unique sibling handles, home-node rules, and circular-move prevention.

Creating Nodes

use AdAstra\Facades\Entries;

$node = Entries::createTreeNode(
    entry: $entry,
    handle: 'about',
    parent: null,
    template: 'templates::pages.entry',
    isHome: false,
);

Creation rules:

Moving Nodes

$moved = Entries::moveTreeNode($node, $newParent, sortOrder: 2);

Move rules:

Deleting Nodes

app(\AdAstra\Services\EntryService::class)->deleteTreeNode($node);

Deleting an EntryTree node runs inside a transaction. The database promotes direct children to root with nullOnDelete; EntryTreeObserver snapshots those children before delete and rebuilds each promoted subtree after delete so uri and depth stay consistent. Deleting the entry itself cascades its tree node.

EntryTree Driver

EntryTreeRouteDriver resolves a URI against entry_trees. Only entries passing published() are served.

The driver eager-loads:

[
    'entry.entryType',
    'parent.entry',
    'children.entry.entryType',
]

Redirect short-circuit. If the matched node has a redirect_url and it passes isSafeRedirect() (relative path or http/https scheme), the driver returns a RouteResult with type: 'entry_tree_redirect' and data = ['url' => $url, 'status' => $node->redirect_status ?: 302].

Template precedence (no redirect). EntryTree.templateEntryType.default_template'entries.show', with the templates:: namespace prefixed.

use AdAstra\Services\SiteRouting\SiteRouter;

$view = app(SiteRouter::class)->render('/blog/my-post');
// Template receives: $entry, $entryType, $node

The selected template receives:

Variable Value
$entry Matched Entry model
$entryType Matched entry's EntryType
$node Matched EntryTree node

Entry Tree routes win over template routes when entry_tree appears before template in site.routing.priority, which is the default.

RouteResult carries type, template, data, resource only; there is no top-level redirect_url property. Redirects piggy-back on the data array.

Template Driver

TemplateRouteDriver maps URL segments to views under packages/core/resources/templates/. Reserved first segments (api, admin, login, logout, register, password, sanctum, storage, assets, vendor) are blocked.

URL Resolved view
/ templates::site.index
/blog templates::blog.index
/blog/my-post templates::blog.entry (with $handle = 'my-post')
/blog/archive templates::blog.archive (if the file exists)

Key/value pairs after the second segment are parsed into $params:

/blog/my-post/page/2  →  $params = ['page' => '2']

The template driver passes these common variables:

Variable Value
$segments All URL segments as an array
$params Key/value pairs from segments after the second segment
$get Query string array from the current request
$segment_1 First segment, when present
$segment_2 Second segment, when present
$handle Second segment for templates::{group}.entry, else null
$tail Segments after the second segment for two-segment routes

Example template:

{{-- packages/core/resources/templates/blog/entry.blade.php --}}
@php
    $entry = \AdAstra\Facades\Content::query()
        ->inGroup('blog')
        ->published()
        ->where('handle', $handle)
        ->firstOrFail();
@endphp

<h1>{{ $entry->title }}</h1>
<div>{!! $entry->field('body') !!}</div>

Configuring Driver Priority

// packages/core/config/site.php
return [
    'routing' => [
        'priority' => [
            'entry_tree',
            'template',
        ],
    ],
    'templates' => [
        'base_path' => 'site',
        'default_template' => 'templates::site.index',
        'not_found_template' => 'errors.404',
    ],
];

SiteRouter currently reads routing.priority. TemplateRouteDriver reads templates.default_template when resolving /. The base_path and not_found_template keys are present in config for future use but are not read by the current route drivers.


Template and View Stack

Admin views are Twig templates under packages/core/resources/views/admin/**/*.twig, powered by TwigBridge. Public templates use the templates:: namespace and are resolved by the site routing layer under packages/core/resources/templates.

The frontend asset pipeline uses Vite 7 and Tailwind CSS 4:

npm run dev
npm run build

This is not a SPA routing setup. Public URL resolution is server-side through SiteRouter, while admin screens are standard Laravel controller/view flows.

TwigBridge Configuration

TwigBridge is the view layer for admin templates, and public site templates also render through Laravel's view system using the templates:: namespace. The app registers two namespaces in AppServiceProvider::boot():

View::addNamespace('templates', resource_path('templates'));
View::addNamespace('admin', resource_path('views/admin'));

Admin templates live under packages/core/resources/views/admin/ and are referenced as admin::... from controllers through the base admin view helper. Public site templates live under packages/core/resources/templates/ and are referenced as templates::... by the route drivers.

View Namespaces and Includes

Field partials are currently loaded from packages/core/resources/views/_fields/ through the default Laravel view path; no _fields namespace is registered. That is fine for the current layout, but moving field partials out of the root view path later will require an explicit namespace.

The admin layout is packages/core/resources/views/admin/_inc/_layout.twig. Some older admin include files remain in the tree even though the layout no longer uses them; treat those as cleanup candidates rather than active structure.

Site Templates

TemplateRouteDriver resolves public template routes by convention:

The driver rejects reserved first segments such as admin, api, login, storage, and vendor, and refuses to render any admin:: view through the public catch-all.

Vite Asset Pipeline

The project uses Vite 7. Local development can be started with composer run dev, which runs Laravel's server, the queue listener, and Vite together. Production builds should use npm run build after dependencies are installed.


API Layer

The API is versioned under /api/v1 and requires Sanctum authentication. API tokens are issued through the admin/account token flows and are checked by Laravel's auth:sanctum middleware.

API Routes

packages/core/routes/api.php currently registers:

Route Controller Notes
/api/v1/users Api\v1\User Resource routes, logged by middleware
/api/v1/entries Api\v1\Entries Resource routes, logged by middleware
/api/v1/account Api\v1\Account@show Authenticated account endpoint

Each route is wrapped in LogRequestResponse, so successful and failed API responses produce api_logs rows.

API Resources and Current Limitations

API response classes live under packages/core/src/Http/Resources/Api.

UserResource returns id, name, email, created_at, and updated_at.

EntryResource now returns the proper entry shape: id, entry_group_id, entry_type_id, title, handle, status_handle, status_is_public, published_at, fields (via fieldArray()), authors ({ id: user_id, display_name }), categories ({ id, title }), created_at, updated_at. OpenAPI attributes on the class match the runtime shape.

Within Api\v1\Account, only show is routed.

API permission strings: current state

Endpoint Permission required
GET /api/v1/users (index) read users
GET /api/v1/users/{id} (show) read users
DELETE /api/v1/users/{id} delete user
GET /api/v1/entries read entries
DELETE /api/v1/entries/{id} delete entry
GET /api/v1/entry-groups read entry groups
GET /api/v1/category-groups read category groups
GET /api/v1/status-groups read status groups
GET /api/v1/statuses read statuses

API Request/Response Logging

LogRequestResponse writes these fields to api_logs:

Column Source
request_route Request path
method HTTP method
user_id Current authenticated user ID, when present
request_payload Sanitized JSON request input
request_headers Sanitized JSON request headers
response_headers Sanitized JSON response headers
response_status_code HTTP response status

Sensitive keys and headers are redacted, including passwords, tokens, authorization headers, cookies, CSRF headers, secrets, and client secrets. Logged JSON is truncated to 4000 characters.

ApiLog uses Laravel's Prunable trait and prunes rows older than 90 days. packages/core/routes/console.php schedules AdAstra\Jobs\PruneApiLogs daily at 02:00, PruneGateBypassLogs at 02:10 (retention is settings-driven; see Super Admin Gate Bypass), and PurgeDeletedMedia at 03:00 for the media cleanup path.


Controller Layer

Base Classes and Helpers

Admin controllers extend AdAstra\Http\Controllers\Admin\Controller, which checks access admin in its constructor. Most admin writes are guarded again by a FormRequest with a resource-specific permission check. API controllers extend AdAstra\Http\Controllers\Api\Controller; they return API resources or JSON responses and share the auth:sanctum route group.

The preferred controller shape is deliberately thin: validate with a FormRequest, delegate mutations to an action/service/facade, and redirect or return a resource. Controllers should not write directly to core Entry attributes; the Entry lifecycle belongs to EntryService and EntryRepository.

Admin Controllers: Catalog

The admin route map is defined in packages/core/routes/admin.php. The active controller families are:

Area Controller(s) Notes
Dashboard Admin\Dashboard Landing metrics and chart endpoint.
Account Admin\Account, Admin\Account\Settings, Admin\Account\Token Current-user profile, preferences, password, and tokens.
Users Admin\User, Admin\User\Status, Admin\User\Token, Admin\User\Layout User CRUD, status/lock management, user tokens, and profile-field layout.
Roles Admin\Role Spatie role management.
Entries Admin\Entry, Admin\Entry\Group, Admin\Entry\Type Entry CRUD and content-model configuration.
Categories Admin\Category, Admin\Category\Group Category tree and group management.
Fields Admin\Field, Admin\Field\Group Field and field-group management.
Layouts Admin\FieldLayout, Admin\FieldLayout\Tab, Admin\FieldLayout\TabElement Layout tabs and field placement.
Statuses Admin\Status, Admin\Status\Group Status palettes and statuses.
Media Admin\Media, Admin\Media\Library, Admin\MediaPicker Media records, libraries, uploads, and picker JSON.
Settings Admin\Settings\Domain System setting domains at /admin/settings/{handle}.

API Controllers: Catalog

API controllers live under AdAstra\Http\Controllers\Api\v1\ and are registered in packages/core/routes/api.php under /api/v1:

Resource Controller Current shape
Users Api\v1\User Full resource controller.
Account Api\v1\Account Account resource; show is routed.
Entry Groups Api\v1\EntryGroups Full resource controller.
Entries Api\v1\Entries Nested under entry groups; full resource controller.
Category Groups Api\v1\CategoryGroups Full resource controller.
Categories Api\v1\Categories Nested under category groups; full resource controller.
Status Groups Api\v1\StatusGroups Full resource controller.
Statuses Api\v1\Statuses Full resource controller.

Admin Route Map

Admin UI routes live under /admin and require auth. Controllers and FormRequests enforce finer-grained authorization for specific actions.

Area Route Pattern Main Controller(s)
Dashboard /admin/dashboard Admin\Dashboard
Account /admin/account, /admin/account/settings Admin\Account, Admin\Account\Settings, Admin\Account\Token
Users /admin/users/* Admin\User, Admin\User\Token, Admin\User\Layout
Roles /admin/roles/* Admin\Role
Category groups /admin/categories/groups/* Admin\Category\Group
Categories /admin/categories/* Admin\Category
Media libraries /admin/media/libraries/* Admin\Media\Library
Media items /admin/media/* Admin\Media
Media picker (JSON) /admin/media/picker Admin\MediaPicker (see Media Picker Endpoint)
Field groups /admin/fields/groups/* Admin\Field\Group
Fields /admin/fields/* Admin\Field
Status groups /admin/statuses/groups/* Admin\Status\Group
Statuses /admin/statuses/* Admin\Status
Entry groups/types /admin/entries/groups/*, nested /types/* Admin\Entry\Group, Admin\Entry\Type
Entries /admin/entries/groups/{group_id}/create, entries Admin\Entry
Field layouts /admin/field-layouts/* Admin\FieldLayout, tab and element controllers
Settings /admin/settings/{handle} Admin\Settings\Domain

Destructive flows generally include a confirm route before the DELETE.


Model Creation and Modification

Services Layer

Services coordinate business operations and should be the preferred home for transaction orchestration, lifecycle decisions, and cross-model writes. Controllers and commands may use facades for convenience; services and repositories should prefer constructor injection or explicit dependencies.

Important service entry points include EntryService, EntryTypeService, EntryGroupService, CategoryService, UserService, EntryAuthorService, MediaStorageService, and Settings.

Repositories Layer

Repositories own persistence details. EntryRepository is intentionally more specialised than a generic fieldable repository because entries combine core attributes, statuses, authors, categories, scalar field values, relationship field values, and tree-adjacent state. For new Entry behaviour, keep the three-layer contract intact: AbstractEntryType subclass, EntryRepository, and EntryService.

Actions Layer

Action classes sit between controllers and services for admin workflows that need request-shaped orchestration, redirects, or small multi-step routines. They should stay thin and should not become a second persistence layer.

FormRequests Layer

FormRequests own two things: authorization and input shape. For fieldable models, request rules combine static model rules with dynamic schema rules from the relevant field layout. Entry requests also validate status handles, author eligibility, category membership, tree options, and redirect metadata.

Observers, Events, and Listeners

Registered observers include StatusObserver, EntryTreeObserver, and FieldValueObserver. User status and lock changes dispatch events that the WriteUserStatusLog listener records into user_status_logs.

Schema Macros

AppServiceProvider::register() defines Blueprint::statusColumns(), the shared denormalized status triple used by status-aware models: status_id, status_handle, and status_is_public.

Transactional Behaviour

Entry creation and update use repository transactions for core entry writes, status assignment, authors, categories, and field values. Some related work, notably entry-tree creation, still happens outside the core transaction: a tree-create failure can leave an entry without a tree row.


Validation Strategy

The admin layer uses dedicated FormRequest classes in packages/core/src/Http/Requests. Those requests own two concerns: authorization (authorize()) and shape/rule validation (rules()).

Common patterns:

This means controller methods should stay thin: validate through FormRequests, then delegate mutations to action, service, or repository classes.


Bot Blocking, Webhooks, and External Integrations

The codebase includes a bot-blocking layer:

The project also includes spatie/laravel-webhook-client and config/webhook-client.php. Treat webhook behavior as integration-ready infrastructure unless a concrete webhook profile has been configured and documented for the installation.

External integration packages currently present include Sanctum, Fortify, Socialite, Spatie Permission, and Spatie Webhook Client. Media handling is now native to the application.


End-to-End Chain Traces

Entry Create Chain

GET/POST /admin/entries/groups/{group_id}/create
  -> Admin\Entry::store(StoreEntryRequest)
  -> app(CreateNewEntry::class)->create($input)
  -> Entries/Content facade
  -> EntryService::create()
  -> EntryTypeRegistry resolves EntryType row + EntryBehavior instance
  -> AbstractEntryType::validate()
  -> EntryRepository::create()
       DB transaction:
         beforeCreate()
         core attributes
         status assignment
         save entry
         sync authors
         sync categories
         apply scalar and relational field values
       afterCreate()
  -> EntryService tree-node handling when tree input is present

Entry Update Chain

PUT /admin/entries/{id}
  -> Admin\Entry::update(EditEntryRequest)
  -> app(EditEntry::class)->update($entry, $input)
  -> EntryService::update()
  -> EntryRepository::update()
       beforeUpdate()
       DB transaction for attributes, status, authors, categories, fields
       afterUpdate()
  -> tree sync/move metadata where applicable

Category Create Chain

POST /admin/categories/{group_id}/create
  -> Admin\Category::store(StoreCategoryRequest)
  -> CreateNewCategory action
  -> CategoryRepository::create()
  -> writes category attributes and field values for the category layout

Media Upload Chain

POST /admin/media/libraries/{library_id}/upload
  -> Admin\Media\Library::upload(UploadMediaRequest)
  -> Library::addMediaFromUpload()
       validate library adapter/disk, allowed MIME types, max size
       store physical file
       create Media row in a transaction
       assign sort_order and status defaults
       delete physical file if DB persistence fails

User Create Chain

POST /admin/users
  -> Admin\User::store(StoreUserRequest)
  -> CreateNewUser action
  -> Users facade / UserService::create()
       hash password
       apply default status
       create User row
       sync roles
       write profile field values
       promote/demote EntryAuthor eligibility when requested

Key Data Flow Summary

Write path (entry creation)

Controller
  └── Content::create('type_handle', $data)
        └── EntryService::create()
              ├── EntryTypeRegistry::resolveByHandle('type_handle')
              │     └── resolves EntryType row → instantiates PHP class
              └── EntryRepository::create(AbstractEntryType, $data)
                    ├── DB::transaction {
                    │     AbstractEntryType::beforeCreate($data) → $data
                    │     Load entryGroup (statusGroup, fieldLayout)
                    │     Entry::save()
                    │     syncAuthors()
                    │     syncCategories()
                    │     applyFieldValues()
                    │       ├── resolveLayoutFields() (type + group merged)
                    │       ├── scalar  → FieldValue::updateOrCreate()
                    │       └── relational → EntryRelationship::create()
                    │   }
                    └── AbstractEntryType::afterCreate($entry, $data) - outside tx

Read path (entry query)

Content::query()
  └── EntryQueryBuilder
        ├── Chainable: inGroup, ofType, published, withStatus,
        │   withAuthor, withCategory, where, orderBy, latest
        └── Terminal: get() / paginate() / first() / firstOrFail()
              └── ->with([
                    'entryGroup', 'entryType', 'creator', 'authors',
                    'categories', 'fieldValues.field.fieldType',
                    'entryRelationships.field',
                    'entryRelationships.relatedEntry'
                  ])

$entry->field('handle')
  ├── Scalar:     fieldValues → resolvedValue()
  └── Relational: entryRelationships → Collection<Entry> sorted by sort_order

Field value storage

field_values
  field_id / fieldable_id / fieldable_type (morph alias)
  value_text | value_integer | value_float | value_date | value_boolean | value_json

entry_relationships  (relational fields only)
  entry_id / related_entry_id / field_id / sort_order

Morph map aliases at a glance

Alias Model Fieldable
entry AdAstra\Models\Entry Scalar + Relational
user AdAstra\Models\User Scalar only
category AdAstra\Models\Category Scalar only
media AdAstra\Models\Media Scalar only
entry_group AdAstra\Models\EntryGroup No
entry_type AdAstra\Models\EntryType No
category_group AdAstra\Models\Category\Group No
field_group AdAstra\Models\Field\Group No
media_library AdAstra\Models\Media\Library No

Media Status Governance

Media items can carry the same denormalised status triple as entries. The feature is optional at the library level: a library with a null status_group_id is "ungoverned" and its media rows leave the triple null.

Schema

Model surface

Media uses AdAstra\Traits\HasStatus, gaining status(): BelongsTo, scopeWithStatus($handle), and scopePublic(). Media::scopePublished() is a backward-compatible alias delegating to scopePublic(): media has no published_at. Media\Library uses AdAstra\Traits\HasStatusGroup, exposing statusGroup, statuses, and defaultStatus.

Upload auto-assignment

HasMediaItems::addMediaFromUpload(UploadedFile, array $attributes) resolves $library->defaultStatus() outside the transaction, then merges the status triple into the create payload as array_merge([defaults], $statusAttributes, $attributes): caller-supplied attributes win. Ungoverned libraries leave the triple null. A governed library with no is_default status also produces a triple-null row (silent fallback).

Validation

EditMediaRequest::rules() exposes:

'status' => [
    'nullable', 'string', 'max:100',
    Rule::exists('statuses', 'handle')
        ->where(fn ($q) => $q->where('status_group_id', $library?->status_group_id)),
],

A non-null status submitted against an ungoverned library returns 422.

Admin UI

Known limits


HasStatus and the Status Sync Registry

StatusObserver listens for Status::updating and cascades changes to denormalised consumer columns. The roster of consumers is supplied by StatusSyncRegistry, which each consumer registers itself with via the HasStatus trait's bootHasStatus() hook.

Contract

A model adopting HasStatus must:

  1. use HasStatus; on the model.
  2. Declare status_id, status_handle, status_is_public in $fillable, and cast status_is_public to boolean.
  3. Add a $table->statusColumns() macro to its create migration (and a deferred FK migration to statuses later, à la media).
  4. Be added to the force-boot list in AppServiceProvider::boot() alongside Entry and Media. Without this, a queue worker that only touches Status (no consumer) would find an empty registry.

bootHasStatus() in local/testing runs a contract check and throws LogicException if $fillable or the boolean cast is missing. Production skips the check.

What the observer does

When a Status row's is_public or handle is dirty on save, the observer iterates StatusSyncRegistry::consumers() and bulk-updates the denormalised triple on each consumer inside a DB::transaction. Consumers using SoftDeletes are queried via withTrashed().

Current consumers

Consumer File scopePublished
Entry packages/core/src/Models/Entry.php composes scopePublic() + published_at <= now()
Media packages/core/src/Models/Media.php aliases scopePublic() (no scheduled-publish concept)

Technical Tutorials

Adding Custom Fields to Any Model

The custom field layer used by Entries, Categories, and Users is reusable on any Eloquent model that should store dynamic, admin-defined scalar field values. The two reusable pieces are:

  1. Fieldable trait (packages/core/src/Traits/Field/Fieldable.php) - adds fieldValues() (morphMany to field_values), field(string $handle): mixed, and fieldArray(): array.
  2. PersistsFieldValues trait (packages/core/src/Traits/Field/PersistsFieldValues.php) - adds setField() and setFields() for writing.

Use this pattern for any model that needs custom fields, such as media, commerce records, profile-like records, or plugin-owned domain models. The examples below use ProductVariant and ProductType as placeholder model names; replace them with the concrete models in your feature.

How the Field Layer Works

Every Fieldable model stores values in the shared field_values table, keyed on (field_id, fieldable_id, fieldable_type). The fieldable_type column holds the morph alias ('entry', 'user', 'media', etc.), which is why getMorphClass() must be used for writes.

This layer is for scalar custom fields. Entry relationship fields are a special entry-only path stored in entry_relationships.

Step 1: Add a Morph Map Alias

Before writing field values for a new model type, add a stable morph alias in AppServiceProvider::boot():

use AdAstra\Models\ProductVariant;
use Illuminate\Database\Eloquent\Relations\Relation;

Relation::morphMap([
    // Existing aliases...
    'product_variant' => ProductVariant::class,
]);

The alias is what gets stored in field_values.fieldable_type. Avoid storing fully-qualified class names because future namespace changes would break those rows.

Step 2: Add the Fieldable Trait to the Model

Add Fieldable to the Eloquent model that should read custom field values:

// packages/core/src/Models/ProductVariant.php
namespace AdAstra\Models;

use AdAstra\Traits\Field\Fieldable;
use Illuminate\Database\Eloquent\Model;

class ProductVariant extends Model
{
    use Fieldable;

    protected $fillable = [
        'name',
        'handle',
    ];
}

This adds three methods: fieldValues(): MorphMany, field(string $handle): mixed, and fieldArray(): array.

Step 3: Create Fields and a Field Layout

Run this in a seeder or Artisan command. Use a model-specific prefix, such as variant_, because field handles are globally unique.

use AdAstra\Models\Field;
use AdAstra\Models\Field\Group as FieldGroup;
use AdAstra\Models\Field\Type as FieldType;
use AdAstra\Models\FieldLayout;
use AdAstra\Models\FieldLayout\Tab;
use AdAstra\Models\FieldLayout\TabElement;

$textType = FieldType::where('object', \AdAstra\Field\Types\Text::class)->firstOrFail();
$textArea = FieldType::where('object', \AdAstra\Field\Types\Textarea::class)->firstOrFail();

$material = Field::firstOrCreate(
    ['handle' => 'variant_material'],
    ['name' => 'Material', 'label' => 'Material', 'field_type_id' => $textType->id]
);
$care = Field::firstOrCreate(
    ['handle' => 'variant_care'],
    ['name' => 'Care Instructions', 'label' => 'Care Instructions', 'field_type_id' => $textArea->id]
);
$color = Field::firstOrCreate(
    ['handle' => 'variant_color'],
    ['name' => 'Color', 'label' => 'Color', 'field_type_id' => $textType->id]
);

$fieldGroup = FieldGroup::firstOrCreate(
    ['handle' => 'variant-details'],
    ['name' => 'Variant Details', 'description' => 'Custom fields for product variants.']
);
$fieldGroup->fields()->syncWithoutDetaching([$material->id, $care->id, $color->id]);

$layout = FieldLayout::create(['name' => 'Variant Field Layout']);
$tab = Tab::create(['field_layout_id' => $layout->id, 'name' => 'Details', 'sort_order' => 1]);

foreach ([$material, $care, $color] as $i => $field) {
    TabElement::create([
        'field_layout_tab_id' => $tab->id,
        'field_id' => $field->id,
        'required' => false,
        'sort_order' => $i + 1,
    ]);
}

The layout is useful for admin UI rendering. The storage layer itself only requires Field rows and the Fieldable model.

Step 4: Write Field Values

$model->getMorphClass() returns the registered morph alias. That alias is the correct value for fieldable_type.

use AdAstra\Models\Field;
use AdAstra\Models\FieldValue;
use AdAstra\Models\ProductVariant;
use AdAstra\Traits\Field\PersistsFieldValues;

// Option A - via PersistsFieldValues in a service
class ProductVariantFieldService
{
    use PersistsFieldValues;

    public function saveFields(ProductVariant $variant, array $fields): void
    {
        $this->setFields($variant, $fields);
    }
}

$service = new ProductVariantFieldService();
$service->saveFields($variant, [
    'variant_material' => 'Organic cotton',
    'variant_care' => 'Machine wash cold.',
    'variant_color' => 'Blue',
]);

// Option B - write directly (mirrors what PersistsFieldValues does internally)

$field = Field::where('handle', 'variant_material')->firstOrFail();

FieldValue::updateOrCreate(
    [
        'field_id' => $field->id,
        'fieldable_id' => $variant->getKey(),
        'fieldable_type' => $variant->getMorphClass(), // 'product_variant'
    ],
    [$field->fieldType->instance()->storageColumn() => 'Organic cotton']
);

In a controller or action, write fields after the model has been saved:

use AdAstra\Traits\Field\PersistsFieldValues;

class UpdateProductVariant
{
    use PersistsFieldValues;

    public function update(ProductVariant $variant, array $input): ProductVariant
    {
        $variant->fill($input)->save();

        if (!empty($input['fields'])) {
            $this->setFields($variant, $input['fields']);
        }

        return $variant->refresh();
    }
}

Step 5: Read Field Values

use AdAstra\Models\ProductVariant;

// Single item - eager-load the full field chain
$variant = ProductVariant::with('fieldValues.field.fieldType')->findOrFail($id);

echo $variant->field('variant_material'); // 'Organic cotton'
echo $variant->field('variant_care');     // 'Machine wash cold.'
echo $variant->field('variant_color');    // 'Blue'

// As an associative array
$variant->fieldArray();
// ['variant_material' => '...', 'variant_care' => '...', 'variant_color' => '...']

// Collection - avoid N+1 with loadMissing
$variants = ProductVariant::query()->get();
$variants->loadMissing('fieldValues.field.fieldType');

foreach ($variants as $variant) {
    echo $variant->field('variant_material');
}

Step 6: Attach Field Groups to the Owning Configuration Model

If the model type has an owning configuration model, attach field groups there. Existing examples include:

Fieldable records Configuration owner Attachment relation
Entry EntryGroup fieldGroups()
Category CategoryGroup fieldGroups()
User UserSchema fieldGroups()
Media Media\Library field_groups()

For a new model family, create the equivalent owner relation only if the admin UI needs to know which fields are available for that model type. The underlying field_values storage does not require an owner relation.

use AdAstra\Models\Field\Group as FieldGroup;
use AdAstra\Models\ProductType;

$type = ProductType::where('handle', 'shirts')->firstOrFail();
$fieldGroup = FieldGroup::where('handle', 'variant-details')->firstOrFail();

$type->fieldGroups()->syncWithoutDetaching([$fieldGroup->id]);

// Inspect attached field groups (for admin UI rendering)
$type->load('fieldGroups.fields.fieldType');
foreach ($type->fieldGroups as $group) {
    foreach ($group->fields as $field) {
        echo $field->handle; // 'variant_material', 'variant_care', ...
    }
}

Morph Map Note

entry, category, user, and media are already registered in AppServiceProvider. New fieldable model types need their own alias before field values are written. If old code wrote a fully-qualified class name into field_values.fieldable_type, those rows will not resolve through the morph map until converted to the registered alias.



Roadmap

AdAstra is in active alpha. Core systems are functional, and the platform is being built in public, so breaking changes should be expected while it evolves. Thoughtful feedback is always welcome.

Capability areas on the horizon: