#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):
RolesPermissionsSeeder: permissions + 3 roles (super admin,admin,user)UsersSeeder: seeds a single super-admin user fromDEV_USER_*env vars (see above); skipped whenAPP_ENV=productionEntryBehaviorSeeder: 11 behaviour rows that bindentry_typesto PHP classes via morph alias (must run before anyentry_typesrow is created)FieldTypeSeeder: 23 field type rows (see Built-in Types)MediaLibrarySeeder:avatarslibrary used byUser::avatar(), plus any other seeded librariesStatusGroupSeeder: three status groups:publication(draft,published,archived),job-status(draft,published,expired,closed), andproduct-status(draft,published,out-of-stock,pre-order,discontinued)CategoryGroupSeeder: category groups + categoriesFieldGroupSeeder: field groups + fields (content-fields,seo-fields,relationship-fields, plus per-group field bundles)EntryGroupSeeder:blogandproductsentry groups, layouts, and typesExtendedEntryGroupSeeder:events,news,pages,jobs,podcast,portfolio,videos,recipes, plus a fallbackgeneralentry groupUserSchemaSeeder: user profile schema (Profile and Bio tabs, fields likefirst_name,last_name,gender,date_of_birth,website,bio,social_twitter,social_linkedin)SettingsDomainSeeder: settings domains and system-level default values
Local/testing only:
EntrySeeder: sample blog posts and productsSandboxedEntryTreeSeeder: sample entry-tree nodes for routing testsSampleApiTokenSeeder: 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:
public/index.phpis already the Laravel front controller.public/.htaccessalready contains the standard Laravel rewrite rules (auth header passthrough, trailing-slash redirect, front-controller fallback): no changes needed for Apache, as long asmod_rewriteis enabled andAllowOverridepermits it.- Run
npm run build(ornpm run buildas part of your CI/build step) before uploading: the production server only needs PHP and Composer dependencies; Node/npm are not required at runtime oncepublic/buildcontains the compiled Vite assets.
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:
- Action classes for users, roles, entries, field groups, field layouts, categories, statuses, settings, and media libraries.
- Entry type classes and
EntryTypeRegistry. - Models for entries, entry trees, metrics, fields, field values, settings,
OAuth tokens, media, categories, statuses, users, roles, and
EntryAuthor(scopes,display_nameaccessor, relationships). - Services for entries, users, category groups, entry groups/types, settings,
site routing, and
EntryAuthorService(promote,demote,sync,getEligible,findByUser). Usermodel:entryAuthor()HasOne relation andisAuthorEligible()helper.UserServiceauthor eligibility paths:is_author/author_display_namekeys increate()andupdate().- Middleware tests for API logging.
- Feature tests for settings, entry status validation, and entry type hooks.
- Feature tests for user account status:
canAccessSystem()model assertions for all five status values, expired suspension and lock auto-expiry, andEnforceUserStatusmiddleware logout/redirect behaviour (packages/core/tests/Feature/LoginTest.php). - The doctor subsystem (
packages/core/tests/Unit/Doctor/,Feature/Console/DoctorCommandTest.php): per-check unit tests asserting on yielded results, runner tests (skip cascade, crash containment,--onlydependency pull-in, topological ordering, cycle detection), the exit-code matrix, JSON envelope shape, and a guard test that validates every tagged check's id and dependencies at CI time.
#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.mddescribes 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_logsis 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:
- Current status badge with suspension expiry or lock-expiry details.
- A status
<select>that dynamically reveals a reason textarea and a "Suspended Until" datetime picker (with one-click presets: +1h, +24h, +3d, +7d, +30d) when "Suspended" is chosen. The reason field is required for all non-active statuses. - A "Remove Account Lock" section (only shown when the account is locked)
with a DELETE form that calls
users.lock.destroyimmediately. - A status history log of the 10 most recent
user_status_logsentries.
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:
- An active, non-revoked token.
- A stored
refresh_token. - Provider values available through
config("oauth_providers.{provider}"), or token metadata containing a token endpoint. - Provider client ID and client secret.
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.
- Column:
value_integer. The stored value is the amount in the currency's minor unit (cents for USD, pence for GBP, yen for JPY, etc.).prepareForStorage()parses the submitted decimal string withmoneyphp/moneyphp'sDecimalMoneyParseragainst the field's configured currency, then writes$money->getAmount()(an integer). - The minor-unit decimal precision comes from the ISO 4217 currency
metadata via
AdAstra\Support\Iso\Currencies::decimals($currency), so JPY fields are stored as whole integers and BHD fields as thousandths without any per-field configuration.
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:
- Per-currency field handles: declare
price_usd,price_eur,price_gbpas separateMoneyfields with different currency settings. - 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
- Write a PHP class extending
AbstractEntryTypeinpackages/core/src/EntryTypes/. - Register a morph alias in
AppServiceProvider::boot()'s morph map under thebehavior.*prefix. - Insert a row into
entry_behaviors(theEntryBehaviorSeederis the canonical example):classis the morph alias, not the FQCN. - Insert (or update) the
entry_typesrow withentry_behavior_idpointing at the new behaviour. - Run
php artisan adastra:validate-class-references(or the fulladastra:doctor) to confirm the morph alias resolves to a real class extendingAbstractEntryType.
#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:
- Seeder-created types reference a concrete
entry_behaviorsrow. - Programmatic types can leave
entry_behavior_idnull and getGeneralEntryTypebehaviour. - Admin-created types can also leave the behaviour empty (the form field is nullable). Pick a behaviour when the type needs hooks or validation.
- Existing rows whose behaviour resolves to a missing or wrong-shape PHP
class throw
RuntimeExceptionat lookup time rather than falling back.
#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:
AdAstra\Facades\Content:AdAstra\Services\ContentService(kept for backward compatibility).AdAstra\Facades\Entries:AdAstra\Services\EntryServicedirectly. Prefer this in new code.
#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 aCollection<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()vsofType():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()inspectsfieldValuesbut notentryRelationships. OnlyEntry::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:
- The entry's type must have
has_entry_tree = true. - Handles are slugified; non-home handles must contain at least one URL-safe character.
- Sibling handles must be unique at the target parent level.
- A home node must be root-level and only one home node may exist.
- The URI is built from ancestor handles. Home nodes resolve to
/. - New nodes are appended after existing siblings using the next
sort_order.
#Moving Nodes
$moved = Entries::moveTreeNode($node, $newParent, sortOrder: 2);
Move rules:
- A node cannot become its own parent.
- A node cannot move beneath one of its descendants.
- The home node must remain at root.
- The handle must remain unique within the new parent level.
- Sibling
sort_ordervalues are rebalanced. rebuildTreeUri()updates the moved node and all descendants.
#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.template →
EntryType.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:
/usesconfig('site.templates.default_template')./{group}triestemplates::{group}.index./{group}/{template}first triestemplates::{group}.{template}.- If that action template does not exist, it falls back to
templates::{group}.entrywith the second segment exposed ashandle.
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:
- CRUD requests map directly to domain resources, such as users, roles, entries, fields, statuses, categories, and media libraries.
- Nested objects have nested request namespaces, such as
Entry\Type,FieldLayout\Tab, andFieldLayout\Tab\Element. - Settings validation is generated from settings configuration and current
domain definitions through
SettingFormRequest,UpdateDomainSettingsRequest, andUpdateUserSettingsRequest. - Entry persistence performs an additional type-level validation step through
the resolved
AbstractEntryType::validate()method before repository writes. - Field value writes are routed through service/repository code so scalar and relationship fields use the correct backing tables.
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:
BotBlockRequestmiddleware.BotBlockServiceProvider.BbValuemodel and bot-block database table.
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
media_libraries.status_group_id(nullable FK tostatus_groups,nullOnDelete) declares the palette available to media owned by the library. FK is wired in2026_05_07_000003_add_media_foreign_keys.php(deferred:status_groupsdoes not exist at the original media migration timestamp).media.status_id(nullable FK →statuses,nullOnDelete),media.status_handle(string, indexed),media.status_is_public(boolean, indexed, default false). The triple is added via theBlueprint::statusColumns()macro registered inAppServiceProvider::register().
#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
- Library create/edit forms include a "Status Group" dropdown with a
— None (ungoverned) —option. - The libraries index shows the attached
StatusGroup.namecolumn (or "Ungoverned"). - Media edit ships a "Publishing" card on the right rail with a Status dropdown, rendered only when the owning library has a status group.
- The media show page surfaces the current status as a coloured badge.
- The library grid view overlays a small status pill on each tile.
#Known limits
- Reassigning a library's
status_group_iddoes not auto-migrate existing media rows; the triple may go stale if the new group lacks the old handle. (Pinned bypackages/core/tests/Feature/Admin/MediaStatusTest.php.) FileUpload::validate()does not filter submitted media IDs by status.- No
MediaResourceexists yet for API consumption. - No bulk-status admin UI on the media index.
#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:
use HasStatus;on the model.- Declare
status_id,status_handle,status_is_publicin$fillable, and caststatus_is_publictoboolean. - Add a
$table->statusColumns()macro to its create migration (and a deferred FK migration tostatuseslater, à lamedia). - Be added to the force-boot list in
AppServiceProvider::boot()alongsideEntryandMedia. Without this, a queue worker that only touchesStatus(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:
Fieldabletrait (packages/core/src/Traits/Field/Fieldable.php) - addsfieldValues()(morphMany tofield_values),field(string $handle): mixed, andfieldArray(): array.PersistsFieldValuestrait (packages/core/src/Traits/Field/PersistsFieldValues.php) - addssetField()andsetFields()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:
- Multi-tenant / multi-site foundations for running multiple isolated sites from one installation.
- Full-text content search with weighted, per-field indexing.
- Content translation and localization for multilingual content.
- Structured data output (schema.org / JSON-LD) for richer SEO.
- A discussion layer for comments and reviews on content.
- An e-commerce module for selling products and managing orders.