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:

  1. Super admin. Users with the super-admin role pass every gate:

    Gate::before(function ($user, $ability) {
        if ($user->isSuperAdmin()) {
            return true;
        }
    });
    
  2. Entering the admin. The access-textstem-admin gate lets in users with any role listed in admin_roles. The list is empty by default, so only super admins get in until you add roles:

    // config/textstemapp.php
    'admin_roles' => ['admin', 'editor'],
    
  3. Each model. Policies check Spatie permissions such as update wranglerpages or list posts. Pages and blocks can also be limited per record.

  4. Publishing. Changing live pages and posts also needs publish wranglerpages or publish 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:

  1. Write and run the migration first, so the generator can read the table.
  2. Add routes under the /textstem prefix with the web, auth:sanctum and textstem.admin middleware.
  3. Register the policy with Gate::policy() (see Access control), and create its permissions.
  4. 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.

esc