Models, casts and building blocks
The base model, the custom casts, defaults, enums, macros, helpers, categories and stubs you build your own features on.
Everything you add to the kit sits on a small set of pieces that already exist: a base model, four casts for JSON and decimal columns, a trait for defaults, a trait for enums, a handful of macros and helpers, a reusable categories system and the stubs behind php artisan make:*. This guide lists each one with the code you type to use it. Read it before writing your first model.
The base model
App\Models\Model is abstract, extends Eloquent's model and adds App\Traits\DatesToUser, which reads and writes every datetime and timestamp attribute in the signed-in person's timezone and stores it in UTC. Extend it for every model you create; the model stub already does. What the trait converts and how a timezone is found is in Timezones and dates.
namespace App\Models;
class Appointment extends Model
{
protected $guarded = [];
}
Models declare $guarded, never $fillable.
Casts for JSON and decimal columns
The four casts live in app/Casts/. Declare them in $casts like any Eloquent cast.
ArrayCast
Turns a JSON column into an App\Classes\ArrayObject. Unlike Laravel's array cast, a change made through the object is written back to the model at once, so you never reassign the whole attribute.
use App\Casts\ArrayCast;
protected $casts = [
'extra_data' => ArrayCast::class,
];
$user->extra_data->get('plan', 'free'); // value or the default
$user->extra_data->has('plan'); // true or false
$user->extra_data->set('plan', 'pro'); // synced to the model
$user->extra_data->removeKey('legacy'); // synced to the model
$user->extra_data['plan'] = 'pro'; // array access works too
$user->extra_data->all(); // plain PHP array
$user->save();
ArrayObject also offers add($value) to push a value, addValue() as an alias of set(), and it implements Livewire\Wireable, so it can be a public property of a Livewire component. set() and removeKey() only touch the model when the data actually changes.
JsonCasts
The same ArrayObject, plus an Eloquent cast per key inside the JSON column. The column takes JsonCasts::class and each key is declared as column.key.
use App\Casts\JsonCasts;
protected $casts = [
'features' => JsonCasts::class,
'features.starts_at' => 'datetime',
'features.country' => SomeCast::class,
];
$model->features->get('starts_at') comes back as a Carbon instance and is serialised again on save. Only keys one level deep are supported: a cast declared as features.a.b is ignored.
ArrayProxyCast
A virtual attribute that reads and writes another JSON column as a plain PHP array, for the places that do not understand ArrayObject, such as a package or an external API. The argument after the colon names the real column.
use App\Casts\{ArrayProxyCast, JsonCasts};
protected $casts = [
'extra_data' => JsonCasts::class,
'extra_data_array' => ArrayProxyCast::class . ':extra_data',
];
Reading $user->extra_data_array returns the raw JSON of extra_data decoded, or []. Assigning an array to it deep-merges the new keys into what the column already holds, with array_replace_recursive, and stores the result in extra_data.
SafeDecimal
A replacement for the native decimal cast. Livewire casts a model attribute before validation runs, and the native cast rejects null and '', so a nullable decimal field bound to a form fails as soon as the person clears the input. SafeDecimal passes null and '' through and rounds anything else. The constructor argument is the number of decimals, 2 by default.
use App\Casts\SafeDecimal;
protected $casts = [
'amount' => SafeDecimal::class, // 2 decimals
'rate' => SafeDecimal::class . ':4', // 4 decimals
];
$model->amount = ''; // stored as null
$model->amount = 9.999; // stored as 10.00
$model->amount; // "10.00", a string with fixed decimals
Defaults with HasDefaults
App\Traits\HasDefaults fills missing values when a model is created. Declare them as a protected $defaults array, or as a defaults(): array method when a value needs PHP. A dot key targets a key inside an array or JSON-cast attribute.
use App\Traits\HasDefaults;
class Team extends Model
{
use HasDefaults;
protected $defaults = [
'status' => 'draft',
'extra_data.lang' => 'es',
];
}
protected function defaults(): array
{
return [
'currency_id' => Currency::query()->value('id'),
'extra_data.lang' => app()->getLocale(),
];
}
Defaults run in the creating event and only replace a value that is missing, null or ''; 0, false and any other value survive. For records that already exist, call $model->applyDefaults() and save when the model is dirty.
Enums with IsEnum
State values are PHP backed enums that use App\Enums\IsEnum. The kit ships four: AnnouncementStatus, CommunicationStatus, CommunicationType and WebhookStatus.
namespace App\Enums;
enum OrderStatus: string
{
use IsEnum;
case Draft = 'draft';
case Paid = 'paid';
}
| Call | Returns |
|---|---|
OrderStatus::options() |
A collection keyed by value with the translated label of each case, ready for <x-select> |
OrderStatus::getOptions('isVisible') |
The same, keeping only the cases whose isVisible() method returns true |
$status->label() |
The case name as a headline, Draft, passed through __() so it can be translated in lang/ |
$status->is('Paid') |
Whether the instance is that case, by name |
Put colours, badges and transitions in methods of the enum, not in the model.
Macros
App\Providers\AppServiceProvider registers these.
| Macro | What it does |
|---|---|
$date->toUserTimezone() |
A copy of a Carbon instance in the signed-in person's timezone, UTC for a visitor |
@userDate($date) |
Blade directive that echoes a date in that timezone as Y-m-d H:i:s |
User::tableName() |
The table of a model without instantiating it, resolved through the query builder |
$collection->whereNotEmpty() |
Drops null, empty arrays, empty and whitespace-only strings, and any other value PHP's empty() rejects, 0 and false included |
$table->getTableName() |
The table a migration Blueprint is working on |
$table->dropColumnWithIndexes($columns) |
Drops the foreign keys and non-primary indexes that touch the given columns, then the columns themselves. On SQLite it reads PRAGMA index_list to find them, so the same migration runs in tests |
Schema::table('orders', function (Blueprint $table) {
$table->dropColumnWithIndexes(['customer_id', 'coupon_code']);
});
Helpers
app/helpers.php loads every file in app/Helpers/ with a glob. The kit ships base.php; a project adds its own file, project.php, and a merge with a new kit version never touches the same file. Every helper is wrapped in function_exists, so a project may redefine one by loading its file first.
| Helper | What it does |
|---|---|
password_rule() |
Laravel's default password rule with a minimum of 8 characters, for validation |
get_all_classes() |
Every class name in Composer's class map plus the declared ones, memoised |
get_classes_of($parent, $prefix) |
The instantiable subclasses of $parent whose name starts with $prefix |
front_resources($prefix, $sameLevel) |
The Laravel Front resource classes under App\Front\Resources, plus the registered ones |
front_resources_on_menu($section) |
Those that answer true to showOnMenu(), belong to the section and pass the viewAny policy for the signed-in person |
front_resources_grouped_on_menu($section) |
The same, grouped by their menu_group, General when they have none |
class_path(...$parts) |
Joins segments with \ into a class name, accepting / or \ in the input |
pwa_public_key() |
The VAPID public key from config('webpush.vapid.public_key') decoded to a byte array, null when unset |
is_ios(), is_android(), is_mobile() |
Whether the request comes from the wrapped mobile app: an app-platform cookie or a platform_override session value for iOS, an android-app:// referer or the session value for Android |
lastUrl() |
The last non-Livewire URL the person opened, kept in the session by a middleware |
confirmUserAction($component, $method, $params) |
The modal descriptor that asks for a password or PIN before calling $method; see Protect sensitive actions |
trackingEvent($name, $payload) |
Records a visitor tracking event; does nothing unless config('features.tracking_enabled') is on |
money_format($value) |
$ followed by the value with two decimals and thousands separators |
normalize_phone_number($number) |
The number in E.164, guessing the country from the signed-in person, the detected country or config('app.country_code_fallback'); null when nothing parses |
locale_url($locale) |
The URL of the language switch route, /locale/es |
help_markdown($markdown) |
Renders a help-centre Markdown body with the design system's classes |
billingCoreAvailable(), billingCoreManager(), billingCoreModel($name), billingUserBillingAvailable(), billingUser(), billingDashboardMetrics() |
Guards that return false or null when the billing package is absent or disabled, so the rest of the kit can call billing without a hard dependency |
validateGetThumb($url) |
false for DiceBear and Gravatar URLs, so avatar thumbnails are not regenerated for them |
Categories
Any model can carry categories of a named type, with a nested tree managed from the admin panel. Your users can add categories of their own next to it.
The model and the tables
App\Models\Category has type, a string of up to 20 characters that separates one family of categories from another, name, slug, description, parent_id and order_column. The slug is generated from the name, unique within the type and the owner (global categories compete only among themselves), so two users can each have a work. A slug you set yourself is kept when it is free there and gets a numeric suffix when it is not; a saved slug does not change when the name does. Categories are sortable among their siblings, soft deleted, searchable by name and logged in the activity trail. parent() and children() give the tree.
A category with no owner is global: it belongs to the catalogue the administrator manages, and everybody sees it. A category with an owner belongs to that user, and only they see it. Category::query()->visibleTo($user) returns the globals plus that user's own, $category->isOwnedBy($user) tells you whether it is theirs, and owner() is the relation.
The pivot table categorizables has category_id, categorizable_type, categorizable_id and timestamps. Migration 2026_04_05_000001 creates it; the categories table comes with the kit as well.
Category::getOptions('posts') returns an id-to-name array for a select, with children prefixed - and grandchildren -- , cached for a day and refreshed when a category of that type changes.
Add categories to a model
use App\Traits\HasCategories;
class Post extends Model
{
use HasCategories;
}
The trait boots App\Observers\HasCategoriesObserver and adds:
categories(), amorphToManyrelation toCategory.categories_data, a virtual attribute. Assigning it an array of ids, or a JSON string of ids, keeps them in the session underpending_categories, keyed by the model instance, so a model that is not saved yet can already hold its selection. Ids are normalised to unique integers greater than zero.syncCategories(), called by the observer onsaved, which syncs the pending ids to the pivot and clears them from the session.
$post->categories_data = [1, 3, 5];
$post->save(); // synced on the saved event
$post->categories_data; // [1, 3, 5]
$post->categories; // Collection of Category
Manage them in the admin panel
routes/admin.php registers categories/{type} as admin.categories, rendered by App\Livewire\Shared\Category. At /admin/categories/posts an administrator creates categories with name, slug and description, adds a child under any of them, edits, reorders with up and down arrows, and deletes. Deleting asks for confirmation first, and when the category has subcategories the message says how many are deleted with it. Everything created there is global. A category can carry one image: it uploads as soon as you choose it, with a progress bar and Cancel, its thumbnail and name show in the field, and the buttons on the thumbnail take it off; nothing changes on the category until you save. Taking off or replacing an image uploaded there keeps it in the media library unless you choose Delete from the media library too in the line under the field. The breadcrumb points back to /admin/{crud}, where crud is the resource GoToCategories was given, or the type when it was given none. It names the type translated, or readable when there is no translation, and shows it without a link when that page does not exist.

To reach that page from a resource list, add App\Front\Actions\GoToCategories to the resource's index_actions():
use App\Front\Actions\GoToCategories;
public function index_actions()
{
return [
(new GoToCategories)->setType('posts')->setSection('admin'),
];
}
setType($type, $crud = null) names the category type and, optionally, the resource the page links back to. setSection() defaults to app; pass admin to open the administrator's page.
Let your users add their own
routes/app.php registers the same page at /app/categories/{type} as app.categories, for any signed-in user. There a user:
- sees the global categories of the type and their own, never another user's;
- creates categories that belong to them, and adds subcategories under their own;
- edits and deletes only their own: a global category shows no buttons;
- cannot reorder, so the arrows are not there.
An image added to one of their own categories counts against their file space, and is refused when that space is full; the category keeps the image it already had. On the administrator's page the space limit is not applied.
GoToCategories without setSection() opens this page. The kit ships no resource that uses it, so picture one of your own: a recipes resource at /app/recipes. It gets a Categories button on the list, and a single Category select on the form that offers the globals and the user's own:
use App\Front\Actions\GoToCategories;
use App\Front\Inputs\Categories as CategoriesInput;
public function fields()
{
return [
Inputs\ID::make(),
Inputs\Text::make('Name')->rules(['required', 'string', 'max:255']),
CategoriesInput::make('Category', 'categories_data')->ofType('recipes'),
];
}
public function index_actions()
{
return [
(new GoToCategories)->setType('recipes'), // opens /app/categories/recipes
];
}
An /app resource is open to every signed-in user, so each recipe needs an owner, and the resource needs all of this or one user reaches another's records: a policy that checks the owner on every ability, the list filtered to the signed-in user, the owner set on the server when the record is created, import turned off, and its search and massive_edit routes answering 404 in routes/app.php.
Edit them in a form
In a Laravel Front resource, use App\Front\Inputs\Categories on the categories_data attribute. It is hidden on the index and show pages.
use App\Front\Inputs\Categories as CategoriesInput;
public function fields()
{
return [
Inputs\Text::make('Name'),
CategoriesInput::make('Categories', 'categories_data')
->ofType('posts')
->multiple(),
];
}
Without ofType() the select lists categories of every type; without multiple() it accepts one. It offers only the categories the signed-in user can see, and on save it drops any id they cannot see or that belongs to another type. When editing, a single select shows the category already saved.
In your own Livewire form, use the <x-categories> component, backed by App\View\Components\Categories, which mounts the App\Livewire\Shared\Inputs\Categories component and pushes the chosen ids into the bound property of your component.
<x-categories
wire:model="post.categories_data"
type="posts"
:is-multiple="true"
:initial-value="$post->categories_data"
/>
initial-value is the current selection; show-label="false" hides the "Categories" label. Then save the model and the observer syncs the pivot.
Stubs
The files in stubs/ replace Laravel's own, so php artisan make:* produces code already shaped like the kit.
| Command | What comes out |
|---|---|
make:model Post |
Extends App\Models\Model, uses Searchable, SoftDeletes and WithActivityLog, declares $guarded = [] and $searchable = ['name'] |
make:migration create_posts_table |
id(), timestamps() and softDeletes() |
make:enum OrderStatus |
Uses IsEnum, with sample cases and color() and badge() methods to adapt |
make:observer PostObserver --model=Post |
Empty created, creating, updated, updating, deleted and deleting methods |
make:policy PostPolicy |
Extends App\Policies\BasePolicy with the permission $name set to the model variable; see Roles and permissions |
make:notification OrderPaid |
Extends App\Notifications\Notification with subject(), description() and image(); see Send notifications |
make:job SendInvoice |
A queued job with Queueable; --sync gives the synchronous one with Dispatchable |
front:resource Post |
A Laravel Front resource under /admin/posts with ID and Name inputs; --all also creates the model, migration and policy |
make:agent Support, make:agent Support --structured, make:tool SearchOrders, make:agent-middleware LogPrompts |
The Laravel AI classes; see Connect an AI provider |
Edit a stub when you want every generated file to change; nothing else needs to be registered.