Optional Features
Step 12 -- Optional: Set Up a React/Inertia Site
Public pages render via Blade/Livewire by default. The package also ships an optional Inertia/React rendering mode for public pages -- the admin panel stays Blade/Livewire either way; this only changes how visitors see public pages rendered. Skip this step entirely for a Blade/Livewire-only site.
Prerequisites
An Inertia + React frontend. Jetstream's own "Jetstream with Inertia" option in Step 3
installs a Vue-based Inertia stack (@inertiajs/vue3) -- it is not a path to
React, and choosing it does not help here. Instead:
- New app: start with Laravel's official React starter kit
(
laravel new your-app --react, orcomposer create-project laravel/react-starter-kit) before adding this package, then use the "Vanilla - no Jetstream" option in Step 3 -- the starter kit's own auth scaffolding replaces Jetstream's. - Existing app: add Inertia/React by hand --
@inertiajs/react,react,react-dom, and@vitejs/plugin-reactinpackage.json,inertiajs/inertia-laravelvia Composer, and the React plugin wired intovite.config.js. Follow Inertia's own React setup guide for the exact steps -- there's no artisan command that does this the wayjetstream:installdoes for Vue. laravel/fortify-- used for a role-aware post-login redirect; already present via either path above (the React starter kit depends on it directly; Jetstream, if installed for its Livewire/API/teams support, pulls it in too).
laravel/jetstream itself is always present regardless -- it's a hard Composer
dependency of this package (its default User model uses Jetstream's
HasProfilePhoto trait), independent of whether you ever run jetstream:install.
Need team management? The React starter kit's auth scaffolding is single-user only -- no team switching, no invitations. Jetstream's Vue stack is the only ready-made implementation of that feature in this ecosystem; there's no React version of it. If your React site needs teams, either run
php artisan jetstream:install inertia --teamsand port its team-switching UI to React by hand, or build teams yourself. There's no shortcut here -- factor this in before committing to a React frontend if teams are a hard requirement.
Install
php artisan textstem:install-react
This publishes the React source under resources/js/ -- the rendering engine, a
Prose component and default page template matching the package's one bundled
Blade component and default template, the page shell, and TypeScript types -- and, if
you confirm, appends TEXTSTEM_REACT_RENDERING=true to your .env.
Two things it can't do for you, since they touch files you own and customize:
-
In
resources/js/app.tsx, route pages namedtextstem/*to the published layout:import PublicLayout from '@/layouts/public-layout'; // ... layout: (name) => { if (name.startsWith('textstem/')) { return PublicLayout; } // ... your existing cases }, -
In your own
resources/js/types/global.d.ts, add thetextstemprop to yoursharedPagePropsdeclaration (this can't be a drop-in augmentation file -- TypeScript doesn't merge two separate inline shapes assigned to the same property across files):import type { TextstemSharedProps } from '@/types/textstem-shared'; // ... declare module '@inertiajs/core' { export interface InertiaConfig { sharedPageProps: { // ... your existing keys textstem: TextstemSharedProps; [key: string]: unknown; }; } }
Then confirm @inertiajs/react, react, and react-dom are in package.json (see
Prerequisites above) and build:
npm run build
# or, for development:
npm run dev
What changes
| Concern | Blade/Livewire (default) | React/Inertia (react_rendering: true) |
|---|---|---|
| Public page rendering | WranglerPageController |
ReactPageController |
| Admin panel | Blade/Livewire | Unchanged -- Blade/Livewire |
| Post-login redirect | config('fortify.home') for everyone |
Role-aware: the dashboard route for a user who can access-textstem-admin, / for everyone else |
| Admin bar (edit-this-page overlay) | <x-textstem::adminbar/>, only reflects the page you loaded, not later client-side navigation |
React AdminBar, driven by a shared Inertia prop -- stays correct across client-side navigation |
| Page-props cache | The package's own PageCache (raw HTML) |
A separate cache keyed on path + query, reusing the same textstemapp.cache.* config |
Adding your own components and page templates
Add components and templates the same way you would for Blade: create the PHP class
(App\Wrangler\Components\{Name}), then add a matching React component
(resources/js/wrangler/components/{Name}.tsx) and register it in
components/index.ts. ActiveComponent::build() (not render()) is what the React
path calls -- a component that doesn't override it already gets a safe default (its
raw config as data), the same as it does today. See docs/react-rendering.md for
full details, and docs/Notes-wranglercomponents.md for the PHP side.
Configuration
config/textstemapp.php:
'react_rendering' => env('TEXTSTEM_REACT_RENDERING', false),
Step 13 -- Optional: Live Page Screenshots
The Pages admin's Cards and Columns views illustrate each page. Without any extra setup a page shows its Page image (set on the editor's SEO tab), else the first image in its content, else a plain page icon. With live screenshots turned on, each page shows a picture of how it actually looks, refreshed in the background whenever it or one of its blocks is saved.
Screenshots are optional and fail safe: while they are turned off, the tools are missing, or captures keep failing (3 in a row pause them for an hour), nothing is queued and the cards use the pictures above. Editors never wait on a capture.
How it works
- Saving a page, or adding, changing or removing one of its blocks, queues a
CapturePageScreenshotjob a few seconds later (one waiting job per page, so a burst of edits takes one screenshot). Opening the Cards or Columns view also queues pages that have no screenshot yet or an out-of-date one. - The job loads the page in headless Chrome through a short-lived signed link (valid for 5 minutes, no login), so drafts and private pages are captured as staff see them. Only a page that loads with HTTP 200 at its own address counts; error pages, login screens and redirects fail the capture instead of being saved.
- Images are 640 x 400 WebP (about 20-40 KB), stored on a private disk
(
storage/app/page-screenshots/{page-id}.webpby default) and served only to signed-in staff who can view the page. A page's screenshot is removed when the page is permanently deleted. - Captures are never taken inside a save: if the queue runs jobs immediately
(
QUEUE_CONNECTION=sync), saves queue nothing and you capture with the command below.
Install
In the host app:
composer require spatie/browsershot
npm install puppeteer
Puppeteer normally downloads Chrome (~170 MB) on every npm install. To keep that out
of builds that don't need it, add a .puppeteerrc.cjs to the app root that makes the
download opt-in:
// .puppeteerrc.cjs
module.exports = {
skipDownload: process.env.PUPPETEER_DOWNLOAD_CHROME !== 'true',
};
Then install the browser Browsershot uses, once per server, as the user your queue
worker runs as (it goes into that user's ~/.cache/puppeteer and survives deploys):
npx puppeteer browsers install chrome-headless-shell
On Linux, Chrome also needs system libraries and fonts. On Ubuntu:
sudo apt-get install -y libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 \
libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64 \
fonts-liberation fonts-noto-color-emoji
(On Ubuntu 22.04 use libasound2 instead of libasound2t64.)
Configure
TEXTSTEM_PAGE_SCREENSHOTS=true
Optional settings (see page_screenshots in config/textstemapp.php and
ENV_REFERENCE.md):
# Full paths, when node/npm/Chrome aren't on the queue worker's PATH
TEXTSTEM_SCREENSHOT_NODE=/usr/bin/node
TEXTSTEM_SCREENSHOT_NPM=/usr/bin/npm
TEXTSTEM_SCREENSHOT_CHROME=
# Where the browser reaches this site, when not APP_URL (e.g. behind a proxy)
TEXTSTEM_SCREENSHOT_BASE_URL=
# Self-signed certificates, e.g. Laravel Herd's .test domains
TEXTSTEM_SCREENSHOT_IGNORE_HTTPS_ERRORS=false
# Chrome running as root, e.g. in Docker
TEXTSTEM_SCREENSHOT_NO_SANDBOX=false
TEXTSTEM_SCREENSHOT_QUEUE=default
TEXTSTEM_SCREENSHOT_DISK=local
A queue worker must be running (Step 8). Turn screenshots off in your test suite so tests
never launch Chrome, e.g. in phpunit.xml:
<env name="TEXTSTEM_PAGE_SCREENSHOTS" value="false"/>
Check and capture
php artisan textstem:page-screenshots --check # test the setup with a throwaway screenshot
php artisan textstem:page-screenshots --all # capture every page now
php artisan textstem:page-screenshots --page=17 # capture one page (add --queue to queue instead)
php artisan queue:restart # so running workers pick up the new settings
--check names the fix for the common problems (missing puppeteer, Chrome, system
libraries, sandbox or Node). When screenshots are turned on but not working, the
dashboard's Site Health widget and textstem:health-check show a warning with the
reason.
Laravel Forge
- Add
npm ci --omit=devto the site's deploy script, aftercomposer install(puppeteer is a runtime dependency, so--omit=devis enough). - Over SSH as
forge, in the site folder: runnpm ci --omit=dev, thennpx puppeteer browsers install chrome-headless-shell, and install the system libraries above. - Set
TEXTSTEM_PAGE_SCREENSHOTS=truein the site's environment, deploy, then run the check and capture commands above.
Local development with Laravel Herd
Herd's Node isn't on the queue worker's PATH, and its .test certificates are
self-signed:
TEXTSTEM_PAGE_SCREENSHOTS=true
TEXTSTEM_SCREENSHOT_NODE="/Users/you/Library/Application Support/Herd/config/nvm/versions/node/v22.x.x/bin/node"
TEXTSTEM_SCREENSHOT_NPM="/Users/you/Library/Application Support/Herd/config/nvm/versions/node/v22.x.x/bin/npm"
TEXTSTEM_SCREENSHOT_IGNORE_HTTPS_ERRORS=true
(which node in a terminal shows the path.)
Troubleshooting
--check or Site Health says |
Fix |
|---|---|
Cannot find module 'puppeteer' |
Run npm ci (or npm install) in the app folder, and add it to the deploy script. |
Could not find Chrome / chrome-headless-shell |
Run npx puppeteer browsers install chrome-headless-shell as the queue worker's user. |
error while loading shared libraries |
Install the system libraries above. To see which are still missing: ldd ~/.cache/puppeteer/chrome-headless-shell/linux-*/chrome-headless-shell-linux64/chrome-headless-shell | grep 'not found' |
Running as root without --no-sandbox |
Run the worker as a normal user, or set TEXTSTEM_SCREENSHOT_NO_SANDBOX=true. |
No usable sandbox! |
Ubuntu 23.10 and later block Chrome's sandbox with AppArmor, even for a normal user. Add an AppArmor profile allowing user namespaces for Puppeteer's chrome and chrome-headless-shell binaries (see Deploying on Forge), or set TEXTSTEM_SCREENSHOT_NO_SANDBOX=true. |
The page answered 403 / 404 / 401 or redirected to ... |
The server can't load its own pages at APP_URL: check APP_URL, any password protection on the site, or set TEXTSTEM_SCREENSHOT_BASE_URL. |
Page screenshots are paused after 3 failed attempts |
Fix the cause shown, then --check (a passing check resumes them). |
Memory: each capture uses roughly 150-300 MB while it runs. On small servers, give
screenshots their own queue (TEXTSTEM_SCREENSHOT_QUEUE=screenshots) with a single
worker so they never compete with mail or other jobs.
Configuration
config/textstemapp.php (all keys have defaults):
'page_screenshots' => [
'enabled' => env('TEXTSTEM_PAGE_SCREENSHOTS', false),
'node_binary' => env('TEXTSTEM_SCREENSHOT_NODE'),
'npm_binary' => env('TEXTSTEM_SCREENSHOT_NPM'),
'chrome_path' => env('TEXTSTEM_SCREENSHOT_CHROME'),
'base_url' => env('TEXTSTEM_SCREENSHOT_BASE_URL'),
'width' => 1280, // browser window; the image is half this size
'height' => 800,
'quality' => 80,
'disk' => env('TEXTSTEM_SCREENSHOT_DISK', 'local'),
'directory' => 'page-screenshots',
'queue' => env('TEXTSTEM_SCREENSHOT_QUEUE', 'default'),
'timeout' => 60,
'delay' => 15, // seconds after an edit before capturing
'ignore_https_errors' => env('TEXTSTEM_SCREENSHOT_IGNORE_HTTPS_ERRORS', false),
'no_sandbox' => env('TEXTSTEM_SCREENSHOT_NO_SANDBOX', false),
],
To capture another way (for example a hosted screenshot service), bind your own
implementation of Medialight\Textstem\Domain\Pages\Screenshots\ScreenshotCapturer
in a service provider; the package only binds its Browsershot capturer if nothing else
is bound.
Step 14 -- Optional: Form Data
Form Data lets editors build forms in the admin (questions, steps, show-if rules, file uploads), publish them, and place them on pages with the Active Form page block. Answers are kept in the admin, can be emailed to staff, exported as CSV and posted to a webhook.
It is off until a site turns it on. While off, none of its tables, routes, menu, page block, dashboard widget or schedule is loaded.
The same steps, with troubleshooting, are in Enabling Active Forms.
Turning it on
FORMS_ENABLED=true
php artisan migrate
The migration creates textstem_forms, textstem_form_versions,
textstem_form_submissions and textstem_form_submission_files, plus the Form Data
permissions and a forms-manager role. If the config is cached, run
php artisan config:cache again after changing .env.
The tables are prefixed so they never meet the forms / form_data tables of the
older textstem-forms package; Form Data does not read or change those.
Who can use it
| Permission | Allows |
|---|---|
list forms |
Seeing forms, their questions and settings |
manage forms |
Creating, editing, publishing, deleting and restoring forms |
view form submissions |
Reading answers, marking them read, archived or spam, downloading files and CSV |
delete form submissions |
Deleting answers |
The forms-manager role has all four and can enter the admin. Super admins pass
every check.
What the site needs
-
Livewire on public pages. The form on a page is a Livewire component, so the site's page layout must load Livewire's scripts (Livewire 3 injects them unless that's been turned off).
-
Tailwind classes for the public form. Its views use Tailwind utility classes and expect the
@tailwindcss/formsplugin. Add the package's public views to the site's Tailwindcontentand rebuild the CSS:content: [ // ... './vendor/medialight/textstem-laravel/resources/views/forms/**/*.blade.php', './vendor/medialight/textstem-laravel/resources/views/livewire/forms/public-form.blade.php', './vendor/medialight/textstem-laravel/resources/views/wrangler/components/active-form/*.blade.php', ], -
No older
ActiveFormblock in the app. AnApp\Wrangler\Components\ActiveFormclass, or views inresources/views/wrangler/components/active-form/, take the package block's place. Sites that used the oldtextstem-formspackage may have them; remove them unless you mean to override the block.
For particular features
| Feature | Needs |
|---|---|
| Emails to staff, a copy for the sender | A working mailer. Emails and webhooks are queued, so run a queue worker (Step 8) unless QUEUE_CONNECTION=sync. |
| Removing old and deleted answers | The scheduler (Step 9): forms:prune-submissions runs daily at 03:45. |
| File uploads | A private, writable disk (FORMS_UPLOAD_DISK, default local); shared storage such as S3 on more than one server. Livewire's temporary uploads must work. |
| Robot check | TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY, with the site's hostnames added to the key in Cloudflare. Copies of the answers are only sent from forms with the check on. |
| Forms for signed-in visitors only | FORMS_VISITOR_GUARD (an auth guard) and FORMS_VISITOR_LOGIN_ROUTE, or your own FormSubmitters binding (below). |
| Editors' draft preview in the site's look | A Blade component taking a page prop, named by FORMS_PREVIEW_LAYOUT (default public-layout). Without it the preview is a plain page. |
| Webhooks | Outbound HTTPS. Private and reserved addresses are refused unless FORMS_WEBHOOKS_ALLOW_PRIVATE_HOSTS=true. |
| Rate limits and IP hashes behind a proxy | Trusted proxies set up, so each visitor's own address is seen. |
All the variables are listed in ENV_REFERENCE.md.
Signed-in visitors
Forms can be limited to signed-in visitors. By default the visitor is whoever is
signed in on FORMS_VISITOR_GUARD, and guests are sent to FORMS_VISITOR_LOGIN_ROUTE
and back. To decide this yourself (for example to show staff a link to the visitor's
record on each answer), bind your own implementation in a service provider's
register():
use Medialight\Textstem\Domain\Forms\Contracts\FormSubmitters;
$this->app->singleton(FormSubmitters::class, MyFormSubmitters::class);
Extending Medialight\Textstem\Domain\Forms\Support\GuardSubmitters and overriding
describe() is usually enough.
Customising the look
- Site CSS: every part of a form carries a stable hook class beside its Tailwind
utilities, so a stylesheet can restyle forms without copying views. The block
is
ts-form-block(__inner,__title,__intro,__form); the form ists-form(__step,__errors,__section,__section-title,__grid,__captcha,__actions,__back,__submit); each question ists-field ts-field--<type> ts-field--full|half, plusts-field--requiredandts-field--invalid, withts-field__label,__required,__input,__options,__option,__option-input,__option-label,__help,__error,__limits,__uploading,__files,__headingand__textinside it; the thank-you and closed boxes arets-form-message(--sent,--unavailable,__title,__body,__reference,__button). A rule like.ts-field__input { … }(0,1,0) ties with a utility, so scope it (.ts-form .ts-field__input) to win. Copied views should keep these classes. - The page block: add
resources/views/wrangler/components/active-form/default.blade.php(andpreview.blade.phpfor the page builder). - Any other Form Data view: copy it to
resources/views/vendor/textstem/..., e.g.resources/views/vendor/textstem/forms/fields/short-text.blade.php. - Field types:
textstemapp.forms.field_typesmaps each type name to a class implementingMedialight\Textstem\Domain\Forms\Fields\FieldType. Add your own there; don't rename or remove a type published forms use.
Checking it works
- Sign in as a user with
forms-manager(or a super admin) and open Form Data → Forms. - Create a form, add a question, Publish, and set its status to Open on Settings.
- Add an Active Form block to a page, choose the form, and fill it in on the site.
- The answer appears under the form's Answers tab.