Developing With Textstem
Textstem (medialight/textstem-laravel) is a Laravel package that adds a CMS to a Laravel app. It includes a visual page builder (the Wrangler), posts and collections, a media library, a Livewire admin at /textstem, content approval, a REST API, AI tools, and SEO and accessibility analysis. This page is the starting point for developers building on it, and assumes you know Laravel. To install it, see the Installation Guide.
Architecture
Service providers
Composer auto-discovery loads these providers. You don't need to register anything.
| Provider | Does |
|---|---|
TextstemServiceProvider |
The entry point. Binds services and facades, and loads routes, migrations, views and translations. Registers Livewire and Blade components, policies, the super-admin bypass and scheduled commands. |
CacheServiceProvider |
Environment-based cache settings, including the optional Redis cache |
QueryOptimizationServiceProvider |
Database query tuning |
LoggingServiceProvider |
Environment-based logging |
ImpersonateServiceProvider |
Lets super-admins impersonate users (lab404/laravel-impersonate) |
TextstemServiceProvider also loads:
| Provider | Does |
|---|---|
TextstemEventServiceProvider |
Model lifecycle events (ModelCreated, ModelUpdated, ModelDeleted) and their listeners |
TextstemApprovalsServiceProvider |
Content approval's commands and schedule (textstem:publish-due, textstem:clear-idle-drafts) |
TextstemBladeServiceProvider |
Blade directives such as @picture |
TextstemLangServiceProvider |
Translations |
TextstemMacroServiceProvider |
Collection and string macros |
TextstemFormsServiceProvider |
Form Data, only when FORMS_ENABLED=true |
TextstemBroadcastServiceProvider |
The broadcast auth route and the package's channels |
Livewire and Blade names
| Prefix | Points at | Example |
|---|---|---|
textstem:: (Livewire) |
Every class in src/Livewire/, registered automatically |
<livewire:textstem::dashboard-widgets.welcome /> |
textstem:: (Blade, class-based) |
Medialight\Textstem\View\Components |
<x-textstem::app-layout> |
ts:: (Blade, anonymous) |
resources/views/components |
<x-ts::spinner /> |
textstem:: (views) |
resources/views |
view('textstem::requestaccess') |
Where things live
src/
Actions/ Single-purpose action classes
Console/Commands/ Artisan commands
Domain/ Feature areas: Admin, Approvals, Collections, Forms, Globals, Pages and more
Facades/ Facades
Http/ Admin controllers, API controllers (Http/Controllers/Api), middleware
Jobs/ Queue jobs: AI, assets, SEO, accessibility, screenshots
Livewire/ Admin UI components (PageBuilder, Inputs, Browsers, Modals, Tables)
Mcp/ MCP server, tools and resources
Models/ Eloquent models
Orchestration/ AI providers and context
Policies/ Gate policies
Services/ Business logic
Tools/ Admin tools
Wrangler/ Page builder engine and built-in components
resources/views/
wrangler/ Published to the host app on install
The Wrangler page builder
A page (WranglerPage) holds an ordered set of blocks (WranglerComponent). Each block sits in a named slot of the page's template.
Pages
Medialight\Textstem\Models\WranglerPage uses SoftDeletes, Prunable (trashed pages are removed after a month), Sluggable, HasTags, HasTemplate and HasWorkingCopies.
| Field | Holds |
|---|---|
title, slug |
Title, and a slug made from it |
url |
Full path such as /about/team, rebuilt from the slug and parents on every save |
parent_id |
Parent page (-1 for a top-level page) |
template |
Which page template renders it |
enabled |
1 live, 0 draft |
listed |
1 shows it in menus and the sitemap |
greedy |
When true, child paths with no page of their own load this page |
access |
public, private (signed-in users) or protected (password) |
password |
Password for protected pages, never serialised |
redirect, redirect_permanent |
Send visitors to another URL |
description, keywords, thumbnail, meta |
SEO fields and custom JSON |
working_copy_of, working_copy_base |
Set on drafts of live pages (content approval) |
Protected pages use the package's own page_login and check_access routes. To change the password form, override the textstem::requestaccess view.
Blocks
Each block has a type, a slot, a position within the slot, and a data JSON payload edited in the page builder. A type maps to a class, looked up first in App\Wrangler\Components\ and then in Medialight\Textstem\Wrangler\Components\. The package ships Prose and, with Form Data on, ActiveForm.
A block's view is resources/views/wrangler/components/{type}/{template}.blade.php. The template comes from the block's template field and falls back to default. A missing view renders textstem::wrangler.components.missing.
Page templates
A page renders with resources/views/wrangler/pages/{template}.blade.php in the host app. If that file doesn't exist, the package's own template is used, and then its default template. The <region name="…"> elements in a template are its slots. See Page Templates.
Template commands ({wrt …}) give editors dynamic content such as post lists, dates and embeds.
Rendering
WranglerPageController handles public pages through Laravel's fallback route. PageLoader finds the page by URL and checks access, and PageRenderer builds the blocks into the template.
With TEXTSTEM_REACT_RENDERING=true, ReactPageController renders public pages through Inertia instead. It calls each block's build() method, which returns data, in place of render(), which returns HTML. The admin stays Blade and Livewire either way. See React rendering.
Content approval
People who can edit pages or posts but not publish them send drafts to a publisher, who publishes them, schedules them or sends them back. Publishing is controlled by two permissions, publish wranglerpages and publish posts; upgrading gives them to every role that could already edit, so nothing changes until you take them away from a role. Drafts of live pages are hidden copies (working_copy_of) that a global scope keeps out of every query. Scheduled publishing needs the scheduler. See Content approval.
Models
All models live in Medialight\Textstem\Models\ unless noted.
| Model | Table | Holds |
|---|---|---|
WranglerPage |
wrangler_pages |
Page-builder pages |
WranglerComponent |
wrangler_components |
Blocks on pages |
Post |
posts |
Posts of every collection type, with tags and scheduled publishing |
Collection |
collections |
Post types, each with a schema and admin config |
Asset |
assets |
Media library files |
Category |
categories |
Categories shared by pages, posts and assets |
GlobalOption |
global_options |
Site-wide values (globals) |
Message |
messages |
In-app messages between admin users |
Event |
events |
Activity log: visits and actions, with user agent and visitor ID |
Preview |
previews |
Page preview snapshots, pruned automatically |
Prompt, PromptVersion |
prompts |
AI prompt library and its history |
AiTokenUsage |
ai_token_usage |
Tokens and cost per AI call |
User |
users |
Laravel user with roles (Spatie) and Jetstream traits |
Domain\Approvals\Models\Review, ReviewEvent |
textstem_reviews, textstem_review_events |
Content approval submissions and their history |
WranglerPageVersion, WranglerComponentVersion and PostVersion store a snapshot before each change, which powers version history.
The package sets auth.providers.users.model to its own User. To use your own model, set user_model in config/textstemapp.php.
Facades
These aliases are registered for you:
| Alias | Class | Use it for |
|---|---|---|
Textstem |
Facades\Textstem |
The current page (getPage()), globals (globalVal()), section menus, assets by path, template commands (wrt()) |
Preferences |
Facades\Preferences |
User and site preferences |
Notifier |
Facades\Notifier |
In-app notifications |
Image |
Helpers\Image |
Image URLs and transformations |
These facades exist but aren't aliased. Import them by class:
| Facade | Use it for |
|---|---|
Medialight\Textstem\Facades\SEO |
SEO analysis |
Medialight\Textstem\Facades\Accessibility |
WCAG analysis |
Medialight\Textstem\Facades\Prompt |
The AI prompt library |
Medialight\Textstem\Facades\TableGeneratorFacade |
Building HTML tables |
use Medialight\Textstem\Facades\Textstem;
$page = Textstem::getPage();
$phone = Textstem::globalVal('phone', '');
The Helpers page covers each helper class in full.
REST API
The API lives under /api/v1/. Responses use JSON:API format (timacdonald/json-api). Full details are in the API Reference.
Public
| Method | Endpoint | Does |
|---|---|---|
| GET | /api/v1/public/pages/by-url?url=/about |
Loads a page by URL. Add include_components=true for its blocks grouped by slot. Limited to 30 requests a minute. |
Signed in (Sanctum)
These endpoints need a Sanctum token and are limited to 60 requests a minute. They go through the same policies, and content approval rules, as the admin.
| Resource | Endpoint |
|---|---|
| Pages | /api/v1/pages |
| Blocks | /api/v1/wrangler-components |
| Posts | /api/v1/posts |
| Assets | /api/v1/assets (no single-asset show) |
| Categories | /api/v1/categories |
| Roles, permissions | /api/v1/roles, /api/v1/permissions |
Nested routes:
GET|POST /api/v1/categories/{category}/wrangler-pages
GET|POST /api/v1/categories/{category}/posts
GET|POST /api/v1/wrangler-pages/{wranglerPage}/wrangler-components
GET /api/v1/wrangler-pages/{wranglerPage}/tags
POST|DELETE /api/v1/wrangler-pages/{wranglerPage}/tags/{tag}
GET /api/v1/posts/{post}/tags
POST|DELETE /api/v1/posts/{post}/tags/{tag}
MCP server
With MCP_ENABLED=true, the package serves GET and POST at /mcp (set by MCP_PATH), so AI assistants can read site content. These endpoints need no login, so expose only public content. See MCP.
Configuration
The post-install wizard publishes the package's config/textstem.php to config/textstemapp.php, so read values with config('textstemapp.…'). A top-level key missing from your file falls back to the package default, but nested keys are not merged, so a missing key inside an existing section stays missing. After each update, compare the two files and copy across any new keys.
| Key | Controls |
|---|---|
user_model |
The user model the package uses |
admin_roles |
Roles allowed into the admin, besides super admin |
modules |
Extra admin menu entries (add this key yourself; see Modules) |
asset_disk, uploads.* |
Upload disk, allowed types, size limit, processing |
glide, image_presets, image_breakpoints |
Image server, named sizes (poster, thumb, tile, icon, hero) and breakpoints |
cache.*, use_redis |
Public page cache, and the optional Redis cache in production |
tinymce.* |
Editor key and content style |
ai, openai, anthropic |
AI provider, model and key |
accessibility.*, seo.* |
Analysis settings and report retention |
events.retention_days |
How long activity log entries are kept (default 90) |
approvals.* |
Content approval: two-person rule, idle draft days |
search.* |
Models in the admin search at /textstem/search |
secure_headers, csp.* |
Security headers, and extra Content Security Policy origins for your own scripts, styles and frames |
echo |
Live admin updates through Laravel Echo (TEXTSTEM_ECHO) |
inline-component-editor, inline-option-editor |
Inline admin helpers on the public site |
page_builder.preview_frames |
The view and sandbox used for block previews |
react_rendering |
React rendering of public pages |
page_screenshots.* |
Live page screenshots |
mcp.* |
MCP server |
forms.* |
Form Data |
Every environment variable is listed in the Installation Guide and ENV_REFERENCE.md in the package. Features that are off by default are in Opt-in Features.
Artisan commands
Artisan Commands covers every option.
Setup
| Command | Does |
|---|---|
textstem:post-install |
One-time setup wizard: stack choice, config, views, assets |
textstem:refresh-assets |
Re-publishes the admin JS, CSS and TinyMCE. Run after every update |
textstem:publish-jetstream-views |
Re-publishes Jetstream views |
textstem:install-react {--force} |
Publishes the React rendering source |
textstem:dbseed |
Adds sample content |
textstem:create-placeholders |
Makes a placeholder image for each image preset |
Generators
| Command | Creates |
|---|---|
textstem:make:pagecomponent {name} {--template=} |
A page block class and its views |
textstem:make:templatecommand {name} {--template=} |
A template command (pass --template; see the known issue on Template Commands) |
textstem:make:collection {name} {--wc} |
A page block for showing a collection. The collection itself is created in the admin |
textstem:make:module {name} |
An admin CRUD module: model, controller, policy, requests, table, views. You add the migration, routes, policy registration and menu entry |
textstem:make:tool {name} |
An admin tool |
textstem:make:dashboardwidget {name} {--template=} |
A dashboard widget |
textstem:make:mcp-tool {name}, textstem:make:mcp-resource {name} |
An MCP tool or resource |
Export
| Command | Does |
|---|---|
textstem:export-component {name?} |
Zips a page block for another site |
textstem:export-tool {name?} |
Zips an admin tool for Tools → Install |
textstem:export-widget {name?} |
Zips a dashboard widget |
Database
| Command | Does |
|---|---|
textstem:schema-compare |
Compares the migrations with the live database and suggests fixes |
textstem:migrations:sync {--batch=1} |
Marks every migration as run without running it |
textstem:model-check |
Compares models with their tables |
textstem:normalize-asset-paths |
Strips server paths from stored asset paths |
textstem:normalize-asset-types |
Resets asset types saved as raw MIME types |
Maintenance
| Command | Does |
|---|---|
textstem:health-check {--notify} |
Checks the app and its services |
textstem:security-audit |
Checks dependencies, PHP and server versions for known CVEs |
textstem:publish-due |
Publishes scheduled pages and posts that are due |
textstem:clear-idle-drafts {--dry-run} |
Clears drafts of live content nobody has touched for a while |
textstem:prune-reports {--dry-run} |
Removes old SEO and accessibility reports |
textstem:prune-events {--dry-run} |
Removes old activity log entries |
forms:prune-submissions {--dry-run} |
Removes expired form answers (Form Data) |
textstem:sitemap:generate |
Builds the sitemap |
textstem:page-screenshots |
Checks the screenshot setup or captures pages |
textstem:clear-snapshot-throttles |
Lets version snapshots be taken again straight away |
textstem:update-llm-pricing |
Shows and updates the token prices used for AI cost estimates |
textstem:maintenance-schedule |
Prints suggested maintenance tasks |
cache:manage {action=clear} {--warm-up} |
Clears the cache, and can re-warm it |
Access control
Textstem uses spatie/laravel-permission with Laravel gates and policies. These checks decide what a user can do:
-
Super admin. Users with the
super-adminrole pass every gate:Gate::before(function ($user, $ability) { if ($user->isSuperAdmin()) { return true; } }); -
Entering the admin. The
access-textstem-admingate lets in users with any role listed inadmin_roles. The list is empty by default, so only super admins get in until you add roles:// config/textstemapp.php 'admin_roles' => ['admin', 'editor'], -
Each model. Policies check Spatie permissions such as
update wranglerpagesorlist posts. Pages and blocks can also be limited per record. -
Publishing. Changing live pages and posts also needs
publish wranglerpagesorpublish posts.
The package ships policies for WranglerPage, WranglerComponent, Post, Asset, Category, Collection, GlobalOption, Message, Event, Tag, User and admin tools. See Authorisation.
Register your own policies. The package replaces Laravel's policy name guessing. Every model, including your app's, is looked up as
Medialight\Textstem\Policies\{Model}Policy. Register host-app policies yourself, or they won't be found:// AppServiceProvider::boot() Gate::policy(\App\Models\Subscriber::class, \App\Policies\SubscriberPolicy::class);
The same applies when you replace a package policy with your own subclass.
AI
Every AI call goes through AiService, which uses OpenAI or Anthropic depending on TEXTSTEM_AI_PROVIDER. AI is on by default but does nothing until a key is set:
TEXTSTEM_AI_PROVIDER=openai # or anthropic
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o-mini
AI writes articles, descriptions, teasers, tags, keywords and JSON-LD for pages and posts, plus alt text for images, SEO and accessibility analysis, and the admin chat assistant. Image duplicate checks use fingerprints unless images.comparison_driver is openai.
Each call is logged to ai_token_usage with its cost, viewable under Tools → OpenAI Usage. To add a provider, register an LlmProvider with LlmProviderRegistry. See Textstem AI and AI Provider Setup.
Queues
Most background work runs as queue jobs on the default queue. That covers AI requests, alt text, asset thumbnails, metadata and fingerprints, SEO and accessibility analysis, page screenshots, security audits, approval emails and form emails. In production, use a real queue driver and keep a worker running:
php artisan queue:work --tries=3
For development only, OPENAI_USE_QUEUE=false, SEO_USE_QUEUE=false and ACCESSIBILITY_USE_QUEUE=false run those jobs straight away. See Queues.
Scheduled tasks
The package adds its own tasks to Laravel's scheduler, including scheduled publishing every minute, so the host app needs the usual schedule:run cron entry. The sitemap is not scheduled for you. See Scheduled Tasks.
Extending Textstem
Page blocks
php artisan textstem:make:pagecomponent HeroBlock
This creates app/Wrangler/Components/HeroBlock.php, which extends Medialight\Textstem\Wrangler\ActiveComponent, and views in resources/views/wrangler/components/hero-block/. The $editSetup array builds the block's edit form. render() returns the public HTML, and preview() returns the page builder's preview. For React rendering, override build() to return data instead. See Creating Page Components.
Template commands
php artisan textstem:make:templatecommand LatestPosts --template=posts
This creates a class in app/Wrangler/TemplateCommands/ and a view folder in resources/views/wrangler/template-commands/latestposts/. Editors use it as {wrt latestposts}. See Template Commands.
Collections
Collections are created in the admin, which writes schema.json and config.json to resources/views/wrangler/posts/{slug}/. To show a collection on pages, write a page block (textstem:make:collection {name} generates one). See Collections.
Modules
php artisan textstem:make:module Products
This generates an admin CRUD for a new model: model, controller, policy, form requests, Livewire table, filter and action builders, and views. To finish it:
- Write and run the migration first, so the generator can read the table.
- Add routes under the
/textstemprefix with theweb,auth:sanctumandtextstem.adminmiddleware. - Register the policy with
Gate::policy()(see Access control), and create its permissions. - Add a menu entry in
config('textstemapp.modules').
See Creating Modules.
Admin tools and dashboard widgets
php artisan textstem:make:tool SiteReport
php artisan textstem:make:dashboardwidget RecentSales
Tools go in app/Tools/, appear at /textstem/tools/{name}, and are for super-admins only. Widgets go in app/Livewire/DashboardWidgets/, extend Medialight\Textstem\Livewire\DashboardWidget, and wrap their view in <x-widget>. Both are found without registration, and both can be exported as ZIPs and installed on another site. See Admin Tools and Dashboard.
Admin layout
Add resources/views/layouts/app.blade.php to your app, extend textstem::layouts.app, and fill the head_html, head_script or head_style sections, or push to the scripts and scriptsEnd stacks. See Extending Admin Pages.
Opt-in features
Form Data, live page screenshots, React rendering, the MCP server, the page cache, Scout search, live updates and more are off by default. Opt-in Features lists what each needs and how to turn it on.
Working on the package itself
This section is for people changing Textstem, not for sites that use it. Tests run on Orchestra Testbench with an SQLite database, split into Unit/, Feature/ and Integration/.
./vendor/bin/pest # all tests
./vendor/bin/pest tests/Feature/WranglerPageWebTest.php
./vendor/bin/pest --filter "user can create"
./vendor/bin/phpstan analyse # level 5, src/ and database/
./vendor/bin/pint # code style
The base TestCase runs migrations once per process. Call $this->setupUser() for a signed-in user with every gate open.