orchestra/story
Orchestra Story is a Laravel CMS package for Orchestra Platform that adds a simple content publishing layer for stories, pages, and posts. It provides Eloquent-backed models, migrations, and admin-friendly workflows for managing structured content.
Confirm the platform before the package. Story is not a standalone Laravel CMS — it is a first-party extension of Orchestra Platform v3.2, and it inherits Orchestra's service-provider lifecycle, ACL/role layer, routing, and admin scaffolding. If orchestra/platform (or orchestra/foundation) is not already installed and pinned to a 3.2-compatible line, stop and settle that decision first. Retrofitting Story onto plain Laravel costs more than writing a small posts/pages module yourself.
composer require orchestra/story
php artisan route:list --path=story
php artisan migrate --pretend
The provider is normally picked up by Orchestra's extension discovery. If migrations or views are not loading, look at StoryServiceProvider first — it extends Orchestra\Foundation\Support\Providers\ModuleServiceProvider, so registration flows through $config, $views, $migrations, and the module route conventions rather than raw loadMigrationsFrom/loadViewsFrom calls. A provider that "registers fine" but silently skips migrations is almost always a mismatch between the module convention and a hand-rolled override.
Read these five things, in this order:
Orchestra\Story\Model\Content — the single model behind both posts and pages. Everything hangs off it.Orchestra\Story\Policies\ContentPolicy and Orchestra\Story\AuthServiceProvider — the authorization surface, and the highest-risk area in the package (see Gotchas).php artisan route:list --path=story -v — the admin CRUD routes and their middleware.Orchestra\Story\StoryPlugin — the sanctioned seam for menus, assets, and extra admin screens.First-day smoke test. Log into the Orchestra admin, create one post and one page through the CMS UI, then render them from your own front-end route:
use Orchestra\Story\Model\Content;
Route::get('/blog/{content}', function (Content $content) {
abort_unless($content->status === 'published', 404);
return view('blog.show', compact('content'));
});
Bodies are stored as Markdown. Render through Orchestra's formatter rather than echoing raw content:
{!! Orchestra\Format\Facades\Format::make($content->body) !!}
If Format is not present in your install, fall back to league/commonmark — but pick one and stay with it. Inline-HTML handling differs between the two, so mixing them in production produces inconsistent output across pages.
Do not build your front-end inside Story. Story owns the admin CRUD surface; presentation is yours. Treat it as a headless content source — query Content from your own controllers — and you stay insulated when the admin UI changes.
Use the instance factories, not new Content. v3.2 added static constructors that carry per-type defaults (status, template, type-specific behaviour):
$post = Content::newPostInstance();
$page = Content::newPageInstance(['title' => 'About', 'slug' => 'about']);
Prefer the URL helpers over hand-built links. url(), editUrl(), and deleteUrl() keep links correct when Story changes its routing or slug strategy:
<a href="{{ $content->url() }}">{{ $content->title }}</a>
@can('update', $content)
<a href="{{ $content->editUrl() }}">Edit</a>
@endcan
Put authorization in the controller, not the route. With CanManage removed, every content action is a policy check:
public function edit(Content $content)
{
$this->authorize('update', $content);
return view('content.edit', compact('content'));
}
The route-level equivalent (->middleware('can:update,content')) works, but the controller form is easier to test and harder to break by reordering middleware.
Scope queries explicitly. Don't lean on implicit global scopes for publication state; be explicit so admin views and tests can bypass the filter:
$published = Content::where('status', 'published')
->orderByDesc('published_at')
->paginate(15);
If you need a reusable published() scope, wrap it in your own service/query class rather than forking the model — you want to stay mergeable against upstream Content.
Extend through StoryPlugin, not vendor edits. Menus, nav items, and additional admin screens belong in the plugin. Anything outside that seam is a merge conflict on the next Orchestra bump.
Wire polymorphic relations through a morph map. Content::$morphClass is gone in v3.2, so define the alias explicitly in AppServiceProvider:
Relation::morphMap([
'story_content' => \Orchestra\Story\Model\Content::class,
]);
Then use getMorphClass() anywhere another module needs to attach or link content.
Test the model + policy layer, not the admin HTTP surface. Story's admin routes are slow and heavy to exercise; the authorization logic is where the regressions live:
public function test_editor_cannot_delete_others_content(): void
{
$editor = UserFactory::new()->create();
$content = Content::newPostInstance();
$content->user_id = UserFactory::new()->create()->id;
$content->save();
$this->assertTrue($editor->cannot('delete', $content));
}
Cover view, create, update, and delete with a positive and a negative case per role. That suite is your regression gate for the authorization migration.
Cache rendered bodies if traffic warrants it. Key the cache off updated_at. The CMS itself lives admin-side and never touches the hot path, so caching the rendered front-end output is enough.
The authorization migration is the real upgrade risk. CanManage was removed in favour of AuthServiceProvider + ContentPolicy. Grep for CanManage — any surviving reference is a hard runtime failure on that route. The subtler failure runs the other way: a misregistered policy either 403s legitimate editors or, if never discovered at all, falls open to unrelated authenticated users. Stage the upgrade and exercise every content route under every role before rollout.
Content::$morphClass removal breaks custom polymorphic relations. If any relationship assumed the old property, switch to getMorphClass(), register an explicit Relation::morphMap(), and add a regression test on each relationship that traverses content.
Acl aliases are deprecated. Replace imports with Orchestra\Support\Facades\ACL. The old alias may still resolve today; do it now while the change is purely mechanical.
Provider base-class change. If you subclass StoryServiceProvider, or override its boot/config behaviour, verify against ModuleServiceProvider. Your own app providers should rebase too — mixing provider styles produces confusing double-registration of routes and views.
Re-publish SimpleMDE assets. v3.2.0 ships SimpleMDE v1.9.1. If you published assets from an earlier install, the editor keeps serving the old build until you re-publish and clear compiled caches. Symptom: toolbar renders but behaviour is inconsistent, or scroll-sync/autosave misbehaves.
Slug collisions on edit. Slugs are generated from titles, so renaming a published post without pinning the slug silently breaks live URLs. Pin the slug on publish, keep the slug field explicit in any custom admin view, and assert url() after a rename in tests — a broken content URL is the most user-visible failure after an authorization regression.
Debugging a 403 on a content route:
php artisan route:list --path=story -v # confirm middleware
php artisan about # confirm provider registered
Then check, in order: is AuthServiceProvider registered? Is the policy auto-discovered, or does it need Gate::policy(Content::class, ContentPolicy::class)? Does the user's Orchestra role carry the matching ACL action? The failure is almost always in one of those three — and the ACL/role layer is the one people forget.
Treat the public API as unsettled. v3.2.0 removes two public surfaces and deprecates a third, with zero dependents to catch the next break. Concretely: don't extend Content, don't touch its internals, and don't couple Blade deeply to more than title, body, slug, status, and the URL helpers. If you expect to migrate off later, put an interface or DTO between Story and your views.
You are the maintainer. No dependents means no Stack Overflow answers and no third-party extensions. Budget Story maintenance as a recurring line item tied to every Orchestra release, and name an internal owner before adoption rather than after the first incident.
How can I help you explore Laravel packages today?