pishift / content
Content engine for Laravel sites: structure in code, content in the database, rendering with the site's own components.
Requires
- php: ^8.3
- illuminate/support: ^12.0|^13.0
- inertiajs/inertia-laravel: ^3.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.8|^4.0
- pestphp/pest-plugin-laravel: ^3.2|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 22:36:44 UTC
README
Admin-managed content for Laravel sites. Structure lives in code, content lives in the database, and pages render with the site's own Blade (or React) components. The admin is a React + Inertia app that ships with the package.
- Sections: blocks a page is made of (hero, cards, text…), with typed fields, variants and style options.
- Pages: an ordered list of sections, with draft, publish, revisions and live preview.
- Globals: site-wide settings (identity, menus, theme).
- Collections: lists of records (news, documents…) stored in their own table or an existing Eloquent model, with list filters, badges, record actions and permissions.
- Dashboard and menu: widgets and menu entries that projects register.
- Languages per field (
ar,fr,enby default) with fallbacks, RTL-aware admin in Arabic and French.
Requires PHP 8.3+, Laravel 12 or 13, inertiajs/inertia-laravel 3.
Install
From Packagist:
composer require pishift/content php artisan migrate php artisan vendor:publish --tag=content-config php artisan vendor:publish --tag=content-assets
Open /admin and sign in with a user of the web guard. A new site creates its first one with
php artisan content:user admin@example.com (asks for the password). Abilities decide what each user sees:
// AppServiceProvider::boot() Gate::define('content.manage', fn (User $user) => $user->is_admin); Gate::define('content.publish', fn (User $user) => $user->is_admin);
Without definitions, any signed-in user may manage and publish.
Sections
php artisan content:make-section Cards
creates app/Content/Sections/CardsSection.php and resources/views/content/sections/cards.blade.php.
class CardsSection extends SectionType { public function fields(): array { return [ Field::text('title')->translatable()->required(), Field::repeater('cards', [ Field::icon('icon')->options(['star' => 'Star', 'book' => 'Book']), Field::text('title')->translatable()->required(), Field::link('link'), ])->max(12), ]; } public function variants(): array { return ['grid' => 'Grid', 'list' => 'List']; } }
The view receives $data (values resolved for the current locale), $variant, $classes (from the style options, see style_classes), $section and whatever with() adds.
Field types: text (email(), password(), slug('title') — filled from the title while new, then locked with an edit button), textarea, richText (sanitized), number, toggle, select, icon, date, dateTime, color, link, image, file (media library), upload (straight to a disk), repeater. Common options: translatable(), required(), default(), help(), rules([...]), visibleWhen('field', 'value'), readonly().
For a select over many records (hundreds of students…), search them on the server instead of listing them all:
Field::select('student_id')->fromModel(Student::class, 'full_name', ['full_name', 'national_id'])
The admin shows a search box that loads 20 options at a time, the chosen label appears in forms, read-only fields and list columns, and unknown values are rejected. remote($search, $labels) takes your own callbacks when the options don't come from one model.
Changing a section later
Stored content is kept as it was saved. When fields change shape, raise version() and convert old data in upgrade(); it runs every time old content is read:
public function version(): int { return 2; } public function upgrade(array $data, int $fromVersion): array { if ($fromVersion < 2) { $data['title'] = $data['heading'] ?? null; unset($data['heading']); } return $data; }
Unknown fields and section types are preserved (skipped on the site), so removing code never deletes content.
Pages
// In a controller: the admin-managed page, rendered with its template. $page = Content::page('about') ?? abort(404); // published (or the draft in a signed preview) return view($page->templateView(), ['page' => $page]); // Anywhere $phone = content_global('site.phone'); // a value of a global for the current locale
Pages created in the admin are served by slug through a fallback route (routes.pages). List the keys your own routes depend on in protected_pages so they can't be deleted.
Collections
class NewsCollection extends CollectionType { public function fields(): array { /* … */ } public function driver(): CollectionDriver { return new EloquentDriver(News::class); // or new EntriesDriver for the generic table } public function columns(): array { return ['title' => 'Title', 'status' => 'Status']; } public function filters(): array { return ['status' => ['draft' => 'Draft', 'live' => 'Live']]; } public function badges(): array { return ['status' => ['draft' => 'warning', 'live' => 'success']]; } public function actions(): array { return [Action::make('archive')->label('Archive')->confirm('Archive it?')->handle(fn (News $news) => $news->archive())]; } }
Other hooks: ability(), group(), canCreate(), canDelete($record), navigationBadge(), saved($record, $created), rules($recordId).
Dashboard and menu
$this->app->afterResolving(Dashboard::class, fn (Dashboard $dashboard) => $dashboard ->add('figures', fn () => ['type' => 'stats', 'items' => [['label' => 'Students', 'value' => Student::count()]]], ability: 'content.manage')); $this->app->afterResolving(Navigation::class, fn (Navigation $navigation) => $navigation ->add('reports', 'Reports', fn () => route('reports.index'), icon: 'chart', ability: 'reports.view'));
Widget types: stats, chart (line), table.
Admin colour and logo
The admin uses the package's green and the first letter of content.admin.name until the
project sets its own look in config/content.php (or .env):
'admin' => [ 'brand' => [ 'accent' => '#0A1F5C', // CONTENT_ADMIN_ACCENT; hover and light shades are derived 'logo' => '/images/logo.png', // CONTENT_ADMIN_LOGO; URL or path in public/ ], ],
The colour is applied when the page loads, so the prebuilt admin needs no rebuild. Only
#rrggbb values are used; anything else is ignored.
Admin assets
content.admin.assets:
prebuilt(default): the package's compiled admin, published topublic/vendor/content. The project needs no React. Re-publish after each update;--tag=laravel-assets(Laravel's defaultpost-update-cmd) includes it.vite: the project's Vite builds the admin, for projects with their own admin pages. Point an alias at the package and add an entry:
// vite.config.js resolve: { alias: { '@content': fileURLToPath(new URL('./vendor/pishift/content/resources/admin', import.meta.url)) } }
// resources/admin/app.tsx import { createContentAdmin } from '@content/app'; createContentAdmin({ pages: import.meta.glob('./pages/**/*.tsx') }); // Inertia::render('App/Reports/Index')
Commands
| Command | What it does |
|---|---|
content:make-section Name |
Section class and view from the stubs. |
content:user email [--name=] [--password=] |
Creates an admin user, or sets the password of an existing one. |
content:doctor [--render] |
Checks types (duplicate fields, missing views), templates, stored pages and globals against the current schemas, the published admin assets, and with --render requests every published page. Exits non-zero on errors: run it in CI and after every update. |
content:upgrade [--dry-run] |
Writes content saved by older type versions back at the current version. |
content:types [--path=] |
TypeScript types of what each section, global and collection gives its view. |
content:export [--path=database/content] [--except=global:site] |
Writes pages, globals, collection entries and media (with their files) to a folder that ships with the release; --except leaves items out. |
content:import [--path=database/content] [--force] |
Reads that folder on the server. What the site already has is kept unless --force, so it is safe on every deploy. |
Compatibility rules
The package follows semantic versioning. A site pinned to ^1.0 can always take composer update.
Public API (stable within a major version):
- PHP classes meant to be extended or called:
SectionType,GlobalType,CollectionType,Action, theFields\*classes,CollectionDriverand its drivers,Contentfacade /ContentManagerpublic methods,content_global(),Navigation,Dashboard,Localizer, the models' public methods. - Config keys, route names (
content.*), Blade components, Artisan commands and their options. - The stored formats: page documents, global data, field values. A new release reads everything an older one wrote.
- React exports used by project admin pages:
createContentAdmin,AdminLayout, thecomponents/uiprimitives.
Not public: anything marked @internal (HTTP controllers, middleware, console command classes, caches), Inertia page props, admin page components and CSS classes.
Rules for maintainers:
- Patch (
1.2.x): fixes only. - Minor (
1.x.0): new features; nothing public removed or changed in behaviour. Additive migrations only (new tables or nullable columns). - Deprecate before removing: keep the old API working, add
@deprecatedwith the replacement, and log a deprecation notice. Remove only in the next major, listed inUPGRADE.md. - Never change the meaning of stored data; when a stored format must change, bump its version and upgrade on read, like section types do.
- Every release runs the test suite and
content:doctoragainst a real project before tagging.
Updating a site:
composer update pishift/content
php artisan migrate
php artisan vendor:publish --tag=content-assets --force
php artisan content:upgrade --dry-run # then without --dry-run
php artisan content:doctor --render
Testing
The package has its own suite (Pest + Orchestra Testbench): a bare Laravel app with fixture section types, a global, two collections (one on its own table) and a test layout.
composer install
composer test
GitHub Actions runs it on PHP 8.3 and 8.4 with Laravel 12 and 13 for every push and pull request.
Releasing
composer test # must pass npm install && npm run build # rebuilds dist/ (the prebuilt admin) git add dist && git commit -m "Build admin" git tag v1.x.y && git push --tags
dist/ is committed in the package repository so Composer installs a ready admin. Packagist
picks up the new tag; check it is listed on https://packagist.org/packages/pishift/content
before updating sites.
License
MIT. See LICENSE.