YurbaCMF

YurbaCMF

A lightweight, dependency-free auto-CRUD admin panel and lite content framework for Laravel. The whole interface is plain Blade and vanilla CSS - no front-end build step and nothing extra to compile. Declare a model and a set of fields, and the panel generates the list, forms, validation and persistence.

The YurbaCMF admin panel
What's inside
table_rows
Auto-CRUD
List, create, edit, show and delete - generated from a resource class.
edit_note
17 field types
Text, rich editor, media, relations, repeater, SEO - with tabs, sections and conditional fields.
search
Filters & search
Sortable, searchable columns, list filters and a global search across resources.
perm_media
Media library
Central asset manager and picker, with dependency-free image optimisation and thumbnails.
history
Revisions
Per-record snapshots on save, with one-click restore.
schedule
Scheduled publishing
Draft / scheduled / published workflow with signed preview links.
travel_explore
SEO & sitemap
Polymorphic meta fields and a public XML sitemap.
alt_route
Redirects
Admin-managed URL redirects applied by global middleware.
shield_person
Authorization
Per-resource, per-action gates via Laravel policies, plus soft-delete trash.
translate
Localization
Translatable panel interface (English, Russian, Ukrainian) plus per-locale content fields via ->translatable().

Installation

Requires PHP 8.2+ and Laravel 11+, plus the GD extension for image optimisation (without it, uploads are stored as-is). No JS build step - assets ship pre-built.

Install
composer require yurba/cmf
php artisan yurba:install   # publishes config + assets, creates app/Admin
php artisan migrate         # media, revisions, seo, redirects, content, translations, optimization-log tables

The service provider is auto-discovered. The panel lives at /admin - change the prefix in config/yurba.php.

Gate access
use Yurba\Cmf\Facades\Yurba;

// in a service provider - any truthy check
Yurba::authorizeUsing(fn ($user) => $user->is_admin);

Without a custom rule, a truthy is_admin attribute is required.

First login

The panel authenticates against your app's guard (web by default) - it needs an existing user your gate approves. YurbaCMF ships no user table of its own; it reuses Laravel's auth. Flag a user from Tinker (php artisan tinker):

$user = App\Models\User::first();   // or User::factory()->create([...])
$user->is_admin = true;
$user->save();

Then sign in at /admin with that user's credentials.

Updating

After bumping the package, refresh the bundled front-end assets so the panel's CSS/JS match the new version:

composer update yurba/cmf
php artisan vendor:publish --tag=yurba-assets --force
php artisan migrate

Refresh with the yurba-assets tag, not yurba:install --force - the latter also overwrites your config/yurba.php.

Deploy scripts

The package ships two shell scripts in scripts/ that do the steps above on a server. Copy them next to artisan; they work from their own folder, so they can't run from vendor:

cp vendor/yurba/cmf/scripts/*.sh .
ScriptWhat it does
deploy.shApplies the code as it is: composer install --no-dev from composer.lock, package discovery, vendor:publish --tag=yurba-assets --force, migrate --force, optimize and storage:link. Run it after every deploy.
update-cmf.shUpdates yurba/cmf to the newest release your composer.json allows, then runs deploy.sh. It also removes an old local copy in packages/yurba-cmf, left by sites that used a path repository.
sh update-cmf.sh   # a new YurbaCMF release
sh deploy.sh       # anything else

Both use php composer.phar when it sits in the project, composer otherwise, and stop at the first failing step.


Quick start

From a database table to a full admin screen in three steps. We'll build a blog "Post" resource end to end.

1. Model & migration

YurbaCMF reads and writes your Eloquent model directly - no traits, no base class. Just give it the columns and casts your fields expect (see Model setup for the full mapping).

// database/migrations/xxxx_create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('slug')->unique();
    $table->string('status')->default('draft');
    $table->longText('body')->nullable();
    $table->timestamp('published_at')->nullable();
    $table->timestamps();
});
// app/Models/Post.php
class Post extends Model
{
    protected $guarded = [];

    protected $casts = [
        'published_at' => 'datetime', // the Date field needs a datetime cast
    ];
}
2. Define the resource

A resource is a class in App\Admin that names the model and lists its fields. Each field drives one column across the list, form and detail views; modifiers like searchable() or onlyOnForm() tune where and how it appears.

namespace App\Admin;

use App\Models\Post;
use Yurba\Cmf\Fields\Text;
use Yurba\Cmf\Fields\Editor;
use Yurba\Cmf\Fields\Slug;
use Yurba\Cmf\Fields\Select;
use Yurba\Cmf\Fields\Date;
use Yurba\Cmf\Resources\Resource;

class PostResource extends Resource
{
    public static string $model = Post::class;

    public function fields(): array
    {
        return [
            Text::make('title')->searchable()->sortable()->rules('required|string|max:255'),
            Slug::make('slug')->baseUrl(url('/blog')),
            Select::make('status')->options(['draft' => 'Draft', 'publish' => 'Published']),
            Date::make('published_at')->onlyOnForm(),
            Editor::make('body')->onlyOnForm(),
        ];
    }
}
3. Register it
// config/yurba.php
'resources' => [
    App\Admin\PostResource::class,
],

Prefer to skip the boilerplate? Scaffold the class from an existing table - php artisan yurba:resource Post --from-schema --register maps each column to a sensible field and registers it for you.

That's it

Open /admin and "Posts" is in the sidebar with a full CRUD screen: a searchable, sortable list; create/edit forms with validation; and a detail view. From here everything else is a one-line addition to the same fields() array or resource - filters, row & bulk actions, revisions & scheduled publishing, or SEO fields.


Model setup

The panel reads and writes your Eloquent model directly, so give it the columns and casts the fields expect. This is ordinary Laravel - no traits or base class to add.

What each field needs
FieldColumnCast
Text / Email / Slug / Selectstring-
Textarea / Editortext / longText-
Numberinteger / decimal-
Booleanboolean'boolean' (recommended)
Datedate / timestamp'datetime' (required)
Tags / Repeaterjson'array' (required)
Image / Mediastring- (stores a URL)
BelongsToforeign key, e.g. category_id-
BelongsToManynone - a pivot table + relation-
Computed / SeoFieldnone - virtual-
Migration & model
// database/migrations/…_create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('slug')->unique();
    $table->string('status')->default('draft');
    $table->foreignId('category_id')->nullable()->constrained();
    $table->text('body')->nullable();
    $table->json('tags')->nullable();
    $table->string('cover')->nullable();
    $table->timestamp('published_at')->nullable();
    $table->timestamps();
});
// app/Models/Post.php
class Post extends Model
{
    protected $guarded = [];

    protected $casts = [
        'tags'         => 'array',    // Tags / Repeater fields
        'published_at' => 'datetime', // Date field
    ];

    public function category()
    {
        return $this->belongsTo(Category::class);
    }
}

Array-backed fields (Tags, Repeater) won't persist without an 'array' cast, and Date needs a 'datetime' cast. Make the model writable with $guarded = [] or a $fillable list.


Resource reference

A resource is a class in App\Admin extending Yurba\Cmf\Resources\Resource. Declare a model and its fields; override any member below to shape the screen - each has a sensible default.

Identity & query
MemberDefaultPurpose
static $model-Required. The Eloquent model class.
fields()-Required. The field set - drives the table, form, validation and persistence.
label() / pluralLabel()from class nameSingular / plural display name.
uriKey()kebab-pluralURL segment, e.g. blog-posts.
icon()nullSidebar icon (raw HTML, e.g. a Material Symbols <span>).
title($record)title / name / email / #idHeading shown for a single record.
perPage()config per_pageRows per page.
query()Model::query()Base query for the resource.
indexQuery($request)search + filter + sortOverride to eager-load relations or add global scopes.
globallySearchable()trueInclude in the panel-wide search box.
searchableColumns()fields with ->searchable()Columns the search scans.
Capabilities
MemberDefaultPurpose
usesSoftDeletes()auto-detectedEnables the trash / restore UI (see Revisions & publishing).
canViewAny / canView / canCreate / canUpdate / canDeletepolicy or truePer-action authorization (see Authorization).
hasRevisions() / revisionsLimit()false / 25Version history.
publishing() / previewRoute()nullDraft / scheduled-publish workflow + signed preview.
canExport() / canImport()true / falseCSV export / import (see Actions).
rowActions() / bulkActions()[]Custom per-row / bulk actions (see Actions).
Example
namespace App\Admin;

use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Yurba\Cmf\Resources\Resource;

class PostResource extends Resource
{
    public static string $model = Post::class;

    public function label(): string { return 'Article'; }

    public function icon(): ?string
    {
        return '<span class="material-symbols-rounded">article</span>';
    }

    // eager-load the author so the index list avoids N+1
    public function indexQuery(Request $request): Builder
    {
        return parent::indexQuery($request)->with('author');
    }

    public function fields(): array
    {
        return [ /* ... */ ];
    }
}

Field types

Every field maps a model attribute to a form control and a table cell. Build with Field::make('column', 'Label?') and chain modifiers.

Fields
FieldPurpose
TextSingle-line input (->type() for email/url/number variants).
TextareaMulti-line text; off the index by default.
NumberNumeric input, cast on save.
EmailEmail input.
BooleanToggle switch; renders a pill on the index.
DateDate / datetime picker (->withTime()).
SelectDropdown from ->options([...]).
SlugEditable permalink with live preview; derives from the Sluggable source when blank.
ImageUpload to public/uploads/{dir}; stores the web path.
MediaPick from the media library (or upload a new file).
EditorRich text (YurbaEditor); stores allowlist-sanitised HTML.
TagsComma-separated input backed by an array/JSON column.
BelongsToForeign-key select from a related model.
BelongsToManyMany-to-many multi-select; syncs the pivot on save.
RepeaterEditable table/cards of rows in an array/JSON column.
ComputedDisplay-only value derived from a callback.
SeoFieldMeta title/description, OG image, noindex - stored polymorphically.
Common modifiers
ModifierDescription
->searchable() / ->sortable()Enable search / sort on this column.
->rules('required|max:255')Laravel validation rules.
->onlyOnForm() / ->onlyOnIndex()Show only on the form / only in the table.
->tab('SEO') / ->section('Meta')Group fields into form tabs and titled sections.
->visibleWhen('status', 'publish')Show the field only when another field matches.
->readonly()Render read-only; submitted value ignored on save.
->default($v) / ->placeholder($s) / ->help($s)Default value, placeholder, help text.
->thumb('small')Image / Media fields - thumbnail preset (or pixel width) rendered on the index. See Media library.
Putting it together

A realistic fields() mixing scalar, choice and content fields - each with the modifiers it needs:

use Yurba\Cmf\Fields\{Text, Number, Boolean, Select, Date, Image, Editor, Computed, SeoField};

public function fields(): array
{
    return [
        Text::make('title')->searchable()->sortable()->rules('required|max:255'),
        Number::make('price')->rules('numeric|min:0'),
        Boolean::make('is_featured')->default(false),
        Select::make('status')
            ->options(['draft' => 'Draft', 'publish' => 'Published'])
            ->default('draft'),
        Date::make('published_at')->withTime()->onlyOnForm(),
        Image::make('cover')->dir('covers')->onlyOnForm(),
        Editor::make('body')
            ->toolbar(['bold', 'italic', 'link', 'ul', 'ol'])
            ->maxChars(5000)
            ->onlyOnForm(),
        Computed::make('reading_time', 'Read time')
            ->using(fn ($post) => ceil(str_word_count(strip_tags((string) $post->body)) / 200).' min'),
        SeoField::make('seo')->tab('SEO'),
    ];
}
Configuring relational & complex fields

Most fields work from just a column name, but relations and repeaters need a little setup:

use Yurba\Cmf\Fields\{BelongsTo, BelongsToMany, Repeater, Date, Image, Tags};

// FK select - field name is the FK column; say which model and title column
BelongsTo::make('category_id', 'Category')
    ->relatedModel(Category::class)
    ->title('name')
    ->nullable(),

// many-to-many multi-select - synced to the pivot after save.
// field name is the relation method (override with ->relation('...'))
BelongsToMany::make('tags')
    ->relatedModel(Tag::class)
    ->title('name'),

// repeating rows in a json column - declare the columns
Repeater::make('specs')
    ->columns(['label' => 'Label', 'value' => 'Value'])
    ->addLabel('Add spec')
    ->stacked(),

Date::make('published_at')->withTime(),
Image::make('cover')->dir('covers'),
Tags::make('keywords'),

BelongsTo throws without ->relatedModel(). Tags and Repeater need an 'array'-cast column - see Model setup. A repeater column can be text (default), or an array opting into 'textarea' => true, 'image' => true (upload per row, 'dir'), or 'editor' => true (rich text) - handy for galleries, sliders and repeating rich-content blocks.

Custom fields

Need a control that isn't built in? Scaffold one with php artisan yurba:field ColorPicker - it generates a Field subclass and a Blade partial. Implement component() (the partial to render) and, when the input needs special handling, fill(); then use it in fields() like any other field.


Filters

Return filter objects from filters() to add a filter bar above the list. A filter's value arrives under ?f[key]=… and constrains both the index and the CSV export query.

Built-in filters
FilterColumn typeBehaviour
SelectFilteranyMatch one of ->options([...]) exactly.
BooleanFilterbooleanYes / No / Any tri-state.
DateRangeFilterdateFrom / to date range.

Build each with ::make('column', 'Label?'). The key doubles as the column name; pass a second argument to override the label.

Example
use Yurba\Cmf\Filters\SelectFilter;
use Yurba\Cmf\Filters\BooleanFilter;
use Yurba\Cmf\Filters\DateRangeFilter;

public function filters(): array
{
    return [
        SelectFilter::make('status')->options([
            'draft'   => 'Draft',
            'publish' => 'Published',
        ]),
        BooleanFilter::make('featured'),
        DateRangeFilter::make('created_at', 'Created'),
    ];
}

Actions

Beyond the built-in Edit / Delete, a resource can add custom per-row actions, bulk actions on selected rows, and CSV import / export.

Row & bulk actions

Declare the available actions, then handle them in runAction() / runBulk(). Bulk actions add a checkbox column and a selection bar; the delete key is handled natively.

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;

// one button per row, keyed by action name
public function rowActions(?Model $record = null): array
{
    // 'icon' is a Material Symbols name or your own HTML
    return ['duplicate' => ['label' => 'Duplicate', 'icon' => 'content_copy']];
}

public function runAction(string $action, Model $record): void
{
    if ($action === 'duplicate') {
        $record->replicate()->save();
    }
}

// [key => label]; non-empty enables the selection checkboxes
public function bulkActions(): array
{
    return ['publish' => 'Publish selected'];
}

public function runBulk(string $action, Collection $records): void
{
    if ($action === 'publish') {
        $records->each->update(['status' => 'publish']);
    }
}
CSV import / export
MemberDefaultPurpose
canExport()trueShow Export CSV - respects the active filters & search.
canImport()falseEnable Import CSV - upsert rows by primary key.
exportColumns()pk + field columnsColumns written, so an export round-trips back through import.

Revisions & publishing

Opt-in content workflow: version history, scheduled publishing with a signed preview link, and a soft-delete trash.

Revisions

Return true from hasRevisions() to snapshot a record's attributes on every save into the yurba_revisions table. The panel shows the history and can roll back; entries beyond revisionsLimit() (default 25) are pruned automatically.

public function hasRevisions(): bool { return true; }
public function revisionsLimit(): int { return 50; }
Scheduled publishing

Map your model's status / date columns via publishing(). Records set to the scheduled status with a due date are promoted to published by the yurba:publish-scheduled command.

public function publishing(): ?array
{
    return [
        'status'    => 'status',       // the status column
        'date'      => 'published_at', // when to go live
        'draft'     => 'draft',        // status values ↓
        'scheduled' => 'scheduled',
        'published' => 'publish',
    ];
}

Requires the scheduler. With publish_scheduler on (default) the package registers the every-minute command for you - you only need Laravel's scheduler running in production:

# a single cron entry drives Laravel's scheduler
* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1
Signed preview

Return the frontend route for a record from previewRoute(); the panel wraps it in a 30-minute temporarySignedRoute so editors can preview unpublished drafts. Allow it on the frontend with the signed middleware.

// in the resource
public function previewRoute(Model $record): ?array
{
    return ['blog.show', ['post' => $record->slug]];
}

// on the frontend route
Route::get('/blog/{post}', PostController::class)
    ->middleware('signed')   // or check $request->hasValidSignature()
    ->name('blog.show');
Soft-delete trash

If the model uses Laravel's SoftDeletes trait, YurbaCMF detects it and adds a trash view - filter to trashed rows, restore, or delete permanently. No configuration needed.


SEO & sitemap

A polymorphic SEO block per record (no columns on your table), read back on the frontend, plus a public XML sitemap and URL redirects.

The SEO field

Add SeoField::make() to a resource to edit meta title, description, OG image and a noindex flag. Values live in the shared yurba_seo table keyed to the record - nothing to migrate on your model.

use Yurba\Cmf\Fields\SeoField;

public function fields(): array
{
    return [
        // ...content fields...
        SeoField::make()->tab('SEO'),
    ];
}
Rendering on the frontend

Resolve the block with Seo::resolve($model, $fallback) - it returns the stored values, falling back to whatever you pass, plus a noindex boolean.

use Yurba\Cmf\Seo\Seo;

$meta = Seo::resolve($post, [
    'title'       => $post->title,
    'description' => $post->excerpt,
]);
// => ['title' => ..., 'description' => ..., 'image' => ..., 'noindex' => false]
<title>{{ $meta['title'] }}</title>
<meta name="description" content="{{ $meta['description'] }}">
@if($meta['noindex']) <meta name="robots" content="noindex"> @endif
Sitemap & redirects

The public /sitemap.xml combines static URLs with model-driven sources (see Configuration → Sitemap); records flagged noindex are excluded. Split a large site into several named files with sitemaps - the top level then becomes a sitemap index over /sitemap-{name}.xml. The redirect manager stores old → new URL pairs and applies them through global middleware, optionally counting hits.


Media library

A built-in file library with a picker, dependency-free image optimisation (GD) and thumbnails. Enable and tune it under media in Configuration.

Two ways to attach files
FieldStoresWhen to use
Medialibrary referenceReusable assets - pick from the library or upload into it.
Imageweb pathA one-off upload straight to public/{dir}.
use Yurba\Cmf\Fields\Media;
use Yurba\Cmf\Fields\Image;

Media::make('cover'),
Image::make('thumbnail')->dir('thumbs'),
Optimisation

With media.optimize on, uploads are downscaled to max_width, re-encoded at quality and stripped of metadata. Backfill existing files with yurba:media-optimize. The inline editor uploads its images separately under uploads.dir. Every outcome is recorded to the optimization log (media.log), reachable from Settings → Optimize images.

Thumbnails

Thumbnails are generated on demand, not at upload time: the first request for a size renders it and caches it under {dir}-thumbs/{width}/, served statically thereafter. Sizes come from the named thumb_presets (small / medium / large) - or any raw pixel width.

Declare the size an image field should render on the index with ->thumb(); on the frontend, resolve one with Media::thumb(). Both return the original image untouched when a thumbnail can't be derived (optimisation off, non-raster file, unknown preset).

use Yurba\Cmf\Fields\Media;
use Yurba\Cmf\Media\Media as MediaFile;

// in a resource - render the 'small' preset in the table
Media::make('cover')->thumb('small'),

// on the frontend - a preset name or a raw width
<img src="{{ MediaFile::thumb($post->cover, 'large') }}" alt="">
<img src="{{ MediaFile::thumb($post->cover, 800) }}" alt="">

The Image usage & thumbnails screen (linked from Settings → Panel) lists where each library image is used and which thumbnails exist, and lets you generate or delete thumbnails - per row, in bulk, or across the whole library - and remove unused files.


Configuration

Everything is driven by config/yurba.php (published by yurba:install). Copy the full file below, then see each key explained beneath it.

Full config
// config/yurba.php
return [

    // Branding
    'brand'      => env('YURBA_BRAND', 'YurbaCMF'),
    'logo'       => env('YURBA_LOGO', null),
    'hide_brand' => env('YURBA_HIDE_BRAND', false),

    // Panel interface language (English needs no language pack)
    'locale'  => env('YURBA_LOCALE', 'en'),
    'locales' => [
        'en' => 'English',
        'ru' => 'Русский',
        'uk' => 'Українська',
    ],

    // Routing & auth
    'prefix'     => env('YURBA_PREFIX', 'admin'),
    'middleware' => ['web'],
    'guard'      => env('YURBA_GUARD', 'web'),

    // Your resources & settings pages
    'resources' => [
        // App\Admin\PostResource::class,
    ],
    'settings' => [
        // App\Admin\Settings\GeneralSettings::class,
    ],
    'builtin_settings' => true,

    // Custom panel screens (classes extending Yurba\Cmf\Pages\Page)
    'pages' => [
        // App\Admin\Pages\ReportsPage::class,
        // [App\Admin\Pages\SectionPage::class, 'header', 'Header'],   // with args
    ],

    'action_icons'      => true,
    'publish_scheduler' => true,
    'per_page'          => 20,

    // Extra assets injected into the panel
    'styles'  => [],
    'scripts' => [],
    'head'    => null,
    'foot'    => null,

    // Inline editor image uploads
    'uploads' => [
        'dir'    => 'uploads/editor',
        'max_kb' => 4096,
        'mimes'  => 'jpeg,jpg,png,gif,webp,avif',
    ],

    // Media library
    'media' => [
        'enabled'       => env('YURBA_MEDIA', true),
        'disk'          => env('YURBA_MEDIA_DISK', 'public'),
        'relative_urls' => env('YURBA_MEDIA_RELATIVE_URLS', true),
        'dir'           => 'media',
        'mimes'         => 'jpeg,jpg,png,gif,webp,avif,pdf',
        'max_kb'        => 8192,
        'per_page'      => 28,
        'optimize'      => env('YURBA_MEDIA_OPTIMIZE', true),
        'max_width'     => 2560,
        'quality'       => 82,
        // thumbnails are generated on demand and cached under {dir}-thumbs/{width}/
        'thumb_presets' => ['small' => 320, 'medium' => 640, 'large' => 1280],
        'thumb_default' => 'small',
        // optimization log (Settings → Optimize images → log); log_keep bounds the table
        'log'           => env('YURBA_MEDIA_LOG', true),
        'log_keep'      => env('YURBA_MEDIA_LOG_KEEP', 1000),
    ],

    // URL redirects
    'redirects' => [
        'enabled'  => env('YURBA_REDIRECTS', true),
        'log_hits' => true,
    ],

    // Public XML sitemap
    'sitemap' => [
        'enabled' => env('YURBA_SITEMAP', true),
        'static'  => [],
        'sources' => [
            // [
            //     'model' => App\Models\Post::class,
            //     'route' => 'blog.show', 'param' => 'post', 'key' => 'slug',
            //     'scope' => 'published', 'lastmod' => 'updated_at',
            //     'changefreq' => 'weekly', 'priority' => '0.7',
            // ],
        ],
        // Optional: split into several files. When set, /sitemap.xml becomes a
        //  and each entry is served at /sitemap-{name}.xml.
        // 'sitemaps' => [
        //     'pages' => ['static' => ['/', '/about']],
        //     'blog'  => ['sources' => [/* …as above… */]],
        // ],
    ],

    // Rich-text editor driver: 'yurba' | 'none' | 'custom'
    'editor' => [
        'driver'  => env('YURBA_EDITOR', 'yurba'),
        'styles'  => [],
        'scripts' => [],
        'init'    => null,
        'icons'   => [], // YurbaEditor's icons, by its keys
    ],

    // Progressive UI enhancements (turn any off to fall back to native)
    'ui' => [
        'select' => env('YURBA_UI_SELECT', true),
        'viewer' => env('YURBA_UI_VIEWER', true),
        'icons'  => env('YURBA_UI_ICONS', true),
        'icon_html'    => [], // your own icons in place of the panel's symbols
        'viewer_icons' => [], // YurbaPV's icons, by its keys
    ],
];
Core
KeyDefaultDescription
prefix'admin'URL prefix the panel is mounted under.
middleware['web']Middleware applied to every panel route.
guard'web'Auth guard used to sign in to the panel.
resources[]Registered resource classes (App\Admin\*Resource).
settings[]Your own settings pages (extend SettingsPage).
builtin_settingstrueShow the built-in Panel settings tab.
pages[]Custom panel screens (see Custom pages).
brand'YurbaCMF'Sidebar / page-title brand text.
logonullSidebar logo URL; null uses the bundled logo.
hide_brandfalseShow only the logo in the sidebar (brand still used in titles).
locale'en'Default panel-interface language. English needs no language pack.
localesen / ru / ukLanguages the admin may switch between (code => label). See Localization.
action_iconstrueRender row actions as icons instead of text labels.
publish_schedulertrueRegister the every-minute scheduled-publish command.
per_page20Default rows per page in resource tables.
styles / scripts[]Extra stylesheet / script URLs loaded inside the panel.
head / footnullRaw HTML injected into <head> / before </body>.
Media library - media
KeyDefaultDescription
enabledtrueShow the Media tab and library.
disk'public'Storage disk files live on (local, S3, …).
relative_urlstrueStore site-root-relative URLs (/storage/…) so picked media survives a domain change; off for external CDN/S3 disks.
dir'media'Base directory on the disk.
mimesjpeg,png,webp,pdf,…Allowed upload mimes (SVG excluded on purpose).
max_kb8192Max upload size, in KB.
per_page28Items per page in the media grid.
optimizetrueDownscale + re-encode uploads (GD) and strip metadata.
max_width2560Width (px) images are downscaled to when optimising.
quality82JPEG / WebP re-encode quality.
thumb_presetssmall 320 / medium 640 / large 1280Named thumbnail widths (px). Reference a preset from a field's ->thumb() or Media::thumb().
thumb_default'small'Preset used when a thumbnail is requested without a size.
logtrueRecord every optimization outcome (viewable from Settings → Optimize images).
log_keep1000Cap the log table to the most recent N rows (0 = unlimited).
Editor uploads - uploads

Inline image uploads from the Editor field. Files are moved under public/{dir}.

KeyDefaultDescription
dir'uploads/editor'Public directory inline images are stored in.
max_kb4096Max image size, in KB.
mimesjpeg,png,webp,…Allowed mimes (SVG excluded).
Editor driver - editor
KeyDefaultDescription
driver'yurba'Rich-text driver: 'yurba' (bundled), 'none' (plain <textarea>), or 'custom'.
styles / scripts[]Asset URLs for a custom driver.
initnullJS init snippet for a custom driver (enhances textarea[data-editor]).
UI enhancements - ui
KeyDefaultDescription
selecttrueLoad YurbaUI: enhanced <select> controls, filter dropdown and media picker modal, plus the editor build that opens its menus and dialogs through YurbaUI (off: native controls and the standalone editor).
viewertrueYurbaPV click-to-zoom image lightbox.
iconstrueLoad the Material Symbols icon font (off to self-host).
Sitemap - sitemap
KeyDefaultDescription
enabledtrueServe the public /sitemap.xml.
static[]Extra URLs added verbatim (each loc + optional changefreq/priority).
sources[]Model-driven URL sets - one entry per content type (keys below).
sitemaps[]Optional named sub-sitemaps (name => ['static' => …, 'sources' => …]). When set, the top level becomes a sitemap index.

Each sources entry: model, route (named route), param (route parameter), key (model attribute bound to it), optional scope (query scope[s]), filter (where-equals map), lastmod (column), changefreq and priority. Records flagged noindex in their SEO block are excluded automatically.

Multiple sitemaps. Populate sitemaps to split a large site into several files: /sitemap.xml is then served as a <sitemapindex> and each named set at /sitemap-{name}.xml. Each named entry takes the same static / sources keys. Leave sitemaps empty for a single flat urlset.

Redirects - redirects
KeyDefaultDescription
enabledtrueRedirect manager tab + the global redirect middleware.
log_hitstrueCount each redirect's usage.

Settings pages

Group editable options into a settings tab. Values persist to a JSON store under storage/app - no database, no migration.

Define a page

Extend SettingsPage and return a set of fields - the same field classes resources use. Scaffold with php artisan yurba:settings General.

namespace App\Admin\Settings;

use Yurba\Cmf\Settings\SettingsPage;
use Yurba\Cmf\Fields\Text;
use Yurba\Cmf\Fields\Boolean;

class GeneralSettings extends SettingsPage
{
    public function fields(): array
    {
        return [
            Text::make('site_title', 'Site title')->rules('required'),
            Text::make('support_email', 'Support email')->rules('nullable|email'),
            Boolean::make('maintenance', 'Maintenance mode'),
        ];
    }
}

Register it under settings in config/yurba.php (or pass --register to the command).

Read the values
use Yurba\Cmf\Settings\Store;

$title = Store::get('site_title', 'My site');
$down  = (bool) Store::get('maintenance', false);

The built-in Panel tab (branding, accent, pagination) uses the same store; hide it with builtin_settings => false.


Custom pages

Not everything is a CRUD table. A page is your own screen - a bespoke editor, dashboard or report - that gets a sidebar entry and renders inside the panel shell. Register classes extending Yurba\Cmf\Pages\Page under pages in the config.

Anatomy
MemberPurpose
render($request)GET. Return a view or HTML string (wrapped in the panel chrome), or a full Response (used as-is).
handle($request)POST. Process a submit; redirects back by default.
label() / uriKey() / icon()Sidebar name, URL segment ({prefix}/pages/{uriKey}) and optional icon.
canView($user)Authorization - hide the screen from users who fail it.
inNav()true; return false to keep the screen routable but out of the sidebar (e.g. reached from a hub).
group()null; return a label to list the screen under a sidebar heading (like the Resources / Settings groups).
Example
namespace App\Admin\Pages;

use Illuminate\Http\Request;
use Yurba\Cmf\Pages\Page;

class ReportsPage extends Page
{
    public function label(): string { return 'Reports'; }

    public function icon(): ?string
    {
        return '<span class="material-symbols-rounded">bar_chart</span>';
    }

    public function render(Request $request): mixed
    {
        return view('admin.reports', [
            'signups' => \App\Models\User::whereDate('created_at', today())->count(),
        ]);
    }
}

Register it under pages - as a class string, a [Class, ...args] tuple to pass constructor arguments (stays config:cache-safe, so one parameterised page class can back several entries), or a ready instance. The view returns page content only - YurbaCMF wraps it in the sidebar + header - and it's served at /admin/pages/reports.


Content pages

Editable site copy where each page has its own structure. A content page declares its own fields() (any field types - Repeater for repeating blocks, Image/Media, Editor…) and its values are stored as one JSON document per page. Extend Yurba\Cmf\Content\ContentPage and register it under pages like any custom page.

Declare the schema
namespace App\Admin\Pages;

use Yurba\Cmf\Content\ContentPage;
use Yurba\Cmf\Fields\Editor;
use Yurba\Cmf\Fields\Image;
use Yurba\Cmf\Fields\Repeater;
use Yurba\Cmf\Fields\Text;

class HomeContent extends ContentPage
{
    public function label(): string { return 'Home page'; }

    public function fields(): array
    {
        return [
            Text::make('hero_title'),
            Editor::make('intro'),
            Image::make('hero_bg')->dir('content'),
            // repeating blocks - the whole point of per-page structure
            Repeater::make('features')->columns(['title' => 'Title', 'text' => 'Text']),
        ];
    }
}

A simple page is just Text::make('title') + Editor::make('body'); a rich one adds repeaters, images and conditional fields - same field API as resources, including ->tab('SEO') to split a long form into tabs.

Read it on the frontend
use Yurba\Cmf\Content\Content;

$home = Content::get('home');                 // ['hero_title' => ..., 'features' => [...]]
$title = Content::field('home', 'hero_title', 'Welcome');

// or the global shorthand (same thing), handy in Blade:
content('home', 'hero_title', 'Welcome');

Values persist to the package's yurba_content table - one JSON row per page (keyed by uriKey()). No per-field columns, so the schema is free to change.


Authorization

A global entry gate decides who reaches the panel; per-resource, per-action checks scope what they can do.

Per-resource gates
public function canView($user, $record): bool
{
    return $user->id === $record->author_id;
}
// also: canViewAny, canCreate, canUpdate, canDelete

Each method defers to a Laravel Policy on the model if one defines the matching ability, otherwise override it here. With neither, everything is allowed (still gated by the entry gate).


Localization

The panel interface can be translated. English is built in and needs no language pack; Russian and Ukrainian ship with the package. The admin picks a language from Settings → Panel.

Available languages

List the languages the panel may switch between in config/yurba.php as code => label, and set the default with locale. The choice applies to the panel UI only, not your frontend.

// config/yurba.php
'locale'  => env('YURBA_LOCALE', 'en'),
'locales' => [
    'en' => 'English',
    'ru' => 'Русский',
    'uk' => 'Українська',
],
How strings are translated

Interface strings are plain English keys resolved through Laravel's JSON translations (__('Save changes')). English renders the key verbatim - no file needed - while ru and uk are backed by the bundled lang/{locale}.json. Publish them to override wording or add your own locale:

php artisan vendor:publish --tag=yurba-lang   # copies lang/{ru,uk}.json into your app

Add a language by dropping a lang/{code}.json in your app and listing its code => label under locales. Any key you leave out falls back to the English source string.


Content translation

Separate from the panel interface, your content can be translated per locale. Add a trait to the model and mark fields translatable - the form gains a language bar and stores per-locale values in yurba_translations, with the base row keeping the default locale.

Make fields translatable

Add the HasTranslations trait to the model and call ->translatable() on any field. Fields you don't mark stay shared across all locales.

use Yurba\Cmf\Translations\HasTranslations;

class Post extends Model
{
    use HasTranslations;
}
// in the resource
Text::make('title')->translatable();
Editor::make('body')->translatable();
Read a value

Call tr() for the current request locale; it falls back to the base row when a translation is missing.

{{ $post->tr('title') }}
Enable languages

List the content locales and the default under multilang in config/yurba.php (or toggle them in Settings → Panel). Each locale carries a label and a URL slug.

// config/yurba.php
'multilang' => [
    'enabled' => true,
    'default' => 'en',
    'locales' => [
        'en' => ['label' => 'English', 'slug' => 'en'],
        'uk' => ['label' => 'Українська', 'slug' => 'uk'],
    ],
],

This is your frontend content, independent of the panel-interface language above. Content pages are edited per locale the same way.


Theming

The panel is a light UI themed with --y-* CSS variables. Set an accent from the built-in settings page or override the variables.

Custom accent
[data-yurba] {
    --y-accent: #16a34a;
    --y-accent-ink: #15803d;
}

Brand name, logo, accent and interface language are editable at runtime from Settings → Panel. Turn on Hide brand name to show only the logo.


Commands

Artisan commands ship with the package.

Available
CommandDescription
yurba:installPublish config + assets, create app/Admin.
yurba:resource {Name}Scaffold a resource. --from-schema maps table columns, --register adds it to config.
yurba:field {Name}Scaffold a custom field + its Blade partial.
yurba:settings {Name}Scaffold a settings page.
yurba:media-optimizeDownscale + re-encode oversized library images (thumbnails are generated lazily on demand).
yurba:publish-scheduledPromote scheduled records whose publish time has arrived (run every minute).

Dependencies

Nothing beyond Laravel on the PHP side. The UI bundles a few small, self-contained front-end libraries - and every one of them is optional, toggled from config/yurba.php.

Bundled front-end
LibraryUsed forDisable with
YurbaUI Enhanced select / dropdown controls, modals and the editor's menus and dialogs. ui.select => false
YurbaPV Click-to-zoom image lightbox. ui.viewer => false
YurbaEditor Rich-text editor behind the Editor field. editor.driver => 'none'
Material Symbols Sidebar, resource and action icons (Google font). ui.icons => false

Everything here is optional. Turn any of them off in config/yurba.php and the panel gracefully falls back - a native <select>, a plain <textarea>, no lightbox, or self-hosted icons - with no other changes needed.

Icons of your own

Any icon the panel draws can be your own HTML, such as an inline <svg> or an icon font of the site. Icons left out stay Material Symbols.

WhereWhat it takes
ui.icon_htmlThe panel's own icons, keyed by the Material Symbols name it shows: 'edit' => '<svg …>'. Covers the sidebar, row actions, the media library, the picker and the rest.
editor.iconsYurbaEditor's icons, by its own keys (see its docs).
ui.viewer_iconsYurbaPV's icons: left, right, close, zoom, zoomOut, rotate, download.
icon() of a resource, page or settings pageHTML already: return any markup.
rowActions() iconA Material Symbols name (which ui.icon_html can still replace) or your own HTML.
'ui' => [
    'icon_html' => [
        'edit'   => '<svg viewBox="0 0 24 24" width="20" height="20">…</svg>',
        'delete' => '<i class="my-icon my-icon-trash"></i>',
    ],
],

In your own views, Yurba::icon('edit') draws an icon the same way.