Skip to content

A new site ​

One command turns an empty directory into a Laravel application with the panel on /cms, the modules you chose, an administrator to sign in as, and enough content to look at.

bash
composer create-project webx-ui/site example.local

Then questions, with the answers already worked out from the directory you are standing in:

 What the site is called ... Example
 The domain ................ example.local          ← the directory name
 Database .................. mysql / example · create it? yes
 Languages ................. en
 Modules ................... ◼ pages   ◼ media   ◼ seo    ◼ settings
                             ◼ blocks  ◼ inbox   ◼ menu   ◼ admins
                             ◻ blog    ◻ services  … and nine more content modules
 Demo content .............. yes
 Administrator ............. you@example.com

At the end it prints the panel's address, the login and a generated password. Roughly three minutes, most of it npm install.

Where the server is, is not among the questions on purpose. What .env already says, or 127.0.0.1:3306 as root, is reached for before anything about the database is asked, and a machine that answers is never asked about it. A machine that does not — a local stack may give each database server a loopback address of its own — is told so and asked for the host, the port, the user and the password, with what was just tried as the defaults:

 No answer from 127.0.0.1:3306 as [root] — …actively refused it. Say where the server
 is, or stop here and run again with --db-connection=sqlite.

 Where the database server is .. 127.0.1.14
 The port it listens on ........ 3306
 The user to connect as ........ root
 That user's password .......... ← Enter keeps what .env has

Then it tries again. Three answers that still reach nothing end the run with the message below, and so does the first failure on a run with nobody in front of it — --no-interaction never asks anything:

No answer from 127.0.0.1:3306 as [root] — … Start the server, or say where it is with
--db-host and --db-port, or pass --db-connection=sqlite to stand on a file.

All of it takes flags instead, for a machine that has nobody to ask. The questions belong to webx:setup rather than to Composer, so this is two steps: create-project cannot forward anything to them — after -- its third positional argument is the version, and it answers "Too many arguments".

bash
composer create-project webx-ui/site example.local --no-scripts
cd example.local
cp .env.example .env && php artisan key:generate

php artisan webx:setup --no-interaction \
  --modules=pages,media,seo,settings,blocks,inbox,menu,admins \
  --db=example --locales=en,uk

# a dev copy: the block types and the demo, which setup never writes
php artisan webx:blocks:import --theme --publish
php artisan webx:blocks:offered --install
php artisan webx:demo

--no-scripts is what skips the interactive run, and the two lines after it are the rest of what that script would have done. --modules takes a list, all, or a list after + — the modules a company site gets by default and these on top: --modules=+blog,services.

The same command, six months later ​

Everything after create-project is php artisan webx:setup, and it is written to be run again. On a site that has been live since spring it asks the same questions with the current answers as the defaults, installs what is now ticked and is missing, and leaves alone what is not. That is how a module is installed — there is no second, harder path for a site that already exists:

bash
php artisan webx:setup            # tick `blog`, answer the rest with Enter

Under it, in the order they run:

stepwhat it does
the databasecreates it if it is not there, writes DB_* into .env
composer requirethe Composer half of every module ticked
webx:panel --syncthe entry file, package.json, Vite, the config keys
npm install && npm run buildthe panel's front end — the slow step
migrate, storage:link, webx:admin --superthe database and the first administrator
webx:doctorsays whether all of that actually stands up

What it leaves out on purpose: block types and the demo. The hosting platform makes staging and production sites with this same command, and a block type reaches those only when a person imports it. So setup writes none — not the theme's, not the ones the modules offer — and puts nothing on the home page. It ends by printing the commands for a dev copy, in the order they go on:

bash
php artisan webx:blocks:import --theme --publish                 # the theme's block types
php artisan webx:blocks:offered --install --module=faq --module=… # what the chosen modules offer
php artisan webx:demo                                            # demo content, on top of both

--demo and --no-demo are still accepted, so that old scripts run, and change nothing.

Every one of those runs as its own artisan process, which matters more than it looks. The process running webx:setup booted before it rewrote .env and before composer require: its configuration is the old file and its autoloader has never heard of the module it just installed. A child gets both.

One module, without the questions ​

For a script — or a person who knows which module they want — there are two smaller commands:

commandwhat it does
webx:modulesthe modules webx:setup offers, which of them each requires, and which are installed
webx:modules --jsonthe same, for a script to read
webx:module:add <vendor/package>composer require, webx:panel --sync, npm install, npm run build — and stops at the first that fails
webx:module:add <vendor/package> --no-buildthe same without the build, for a pipeline that builds later
bash
php artisan webx:module:add faq                     # a module id works too
php artisan webx:module:add acme/module-gallery     # so does a package nobody here has heard of
php artisan migrate                                 # its tables: module:add leaves them to the deploy

webx:modules needs no database, so it answers on a machine where the site has none yet. Each entry of the JSON is { id, package, npm, label, default, requires, installed }: id is what --modules takes, requires the ids Composer will bring along with it, and an installed module the catalogue does not know — the site's own, a third party's — is listed by what its package says under extra.webx. webx:module:add is safe to run twice: an installed package skips Composer and still gets the rest, which is also how a run that stopped at npm is finished. Every step that fails is a non-zero exit — nothing is a warning a script could miss.

A script that made the site usually wants into its MCP server next, and there is nobody to press Allow on the consent screen. webx:mcp:token --name=… [--scopes=…] [--admin=…] [--json] prints a token for it — what it acts as and what it reaches is in AI agents.

Existing sites ​

A site that was not made from the skeleton runs the same command. What it needs first is the frame and an .env to write into:

bash
composer require webx-ui/module-admin
php artisan webx:setup

What webx:panel --sync touches ​

A module of the panel is two packages — a Composer one on the server, an npm one in the build — and each Composer package names its own npm half, under extra.webx in its composer.json. The installer walks what is installed and tops up five things:

filewhat it doesif you have already changed it
resources/js/admin.tsadds the import, the stylesheet and the line in modules: []leaves it
package.jsonadds the npm halves that are missingraises only a range that cannot reach the version the server needs
vite.config.jsadds the entry to the laravel() plugin's inputsays so instead
config/webx-*.phpthe vite key, and layout for modules with public pagesnever over a value you chose
AGENTS.mdthe guide for code agents, see belowonly its marked block

The entry file is yours. Only the three marked regions are written into:

ts
// webx:imports
import { pages } from '@webx-ui/module-pages'
// /webx:imports

Everything outside them — resolveAvatar, the WxMediaField you hand to seo(), the plugins — is read by nobody and rewritten by nothing. Delete the markers and the command tells you the four lines to add by hand and exits successfully; it is your file, and erasing them is a fair way of saying so.

Agent guide ​

The same run keeps AGENTS.md in the site root — the file Claude Code, Codex and Cursor read first. Between <!-- webx:agents --> and <!-- /webx:agents --> it says what the site is built on, the first rule (never fork a module or edit vendor/; configure, publish a view, patch a screen, register or replace a service instead; any form is <x-webx-form> with <x-webx-field>, answered by FormResponse — Forms) and links every installed package that explains itself:

md
- [webx-ui/module-pages](vendor/webx-ui/module-pages/AGENTS.md) — Pages for the WebX UI admin panel: …

The links go into vendor, so the text matches the version installed. That block is rewritten on every run; everything outside it is yours — write the site's own notes under ## This project. A file you wrote before the first run keeps every word, with the block put on top. CLAUDE.md is written once as the single line @AGENTS.md, and a CLAUDE.md of your own is left alone. Commit both with the site. Where webx-ui/mcp is installed, the same run puts the site's own panel into .mcp.json under the site's address, so that Claude Code opened in this folder connects to this site and no other (see AI agents). The block also points at Where the styles live, the page for changing the site's design without touching a package.

The layout ​

components/layout.blade.php — the theme's, or the site's own in resources/views on a site without one — is the document every public page is printed in, and the agreement between it and the modules is two lines long: a head slot, and the default slot for the content. A page, an article, a rubric asks for it by name — that is what config('webx-pages.layout') holds — and stands inside it.

blade
<head>
    {{ $head ?? '' }}
    @stack('head')
</head>
<body>
    <x-header />
    <main>{{ $slot }}</main>
    <x-footer />
</body>

Both lines are needed and for different reasons. A slot is one place, and what a block type or a partial pushes cannot reach it; a layout without @stack('head') loses metatags silently — the page still looks finished, and you find out from a search engine. webx:doctor refuses on exactly that.

Nothing else is agreed, and nothing else should be. There is no CSS framework anywhere in the packages: a module's view is semantic markup with class names, and the eighty lines of stylesheet inside the layout are there so that "unstyled" does not mean "broken". Delete that <style> block the day you start on the real design.

Demo content ​

bash
php artisan webx:demo             # fill the site
php artisan webx:demo --remove    # take it back out

Each module brings its own and knows nothing about the others': blocks seeds three block types, pages a home page and one below it, media a few pictures, inbox a contact form, blog a rubric and two articles — one dated ahead, so that "Scheduled" is visible. A module whose requirements are not installed is skipped with the reason said out loud rather than silently.

The demo is written in English, Russian and Polish — pages with an address in each, menus, the contact form, the settings, the blog, services, events, vacancies, press and recipes — and keeps the languages your site publishes in. If your main language is not among them it gets the English, so a site in German still has a demo it can read. The FAQ, reviews, team, tariffs and banners are in English and Russian so far.

webx:setup says so beside the webx:demo line it prints, one line per language the chosen modules and theme do not speak:

WARN  The demo is not written in de for: pages, faq, blocks, seo, settings.
      It seeds in English there, under the default language.
WARN  The demo is not written in pl for: faq.
      Those pages show the de words in pl until somebody translates them.

It is a warning and nothing more: the demo seeds all the same. Leave the language out of --locales for now, or translate those records in the panel.

Removal follows a journal in storage/app/webx-demo.json, backwards, and knows nothing else: what the demo wrote down is what comes out, and everything you have written since stays. Rows that existed before the demo filled them — the home page arrives with a migration — are put back as they were rather than deleted, which means an unpublished home page becomes unpublished again and / answers 404 until you publish it.

The layout is files, not rows, so none of this touches it. After --remove the site is working and empty.

One module at a time ​

A module installed after the first seed does not need the whole site emptied and seeded again:

bash
php artisan webx:demo --module=services             # seed only this one
php artisan webx:demo --remove --module=services    # take only its part back out

--module repeats (--module=blog --module=media). The journal knows which module wrote each entry, so a module it already holds is refused unless you add --force, and the rest of the journal is left alone either way. A requirement whose demo is not in the journal yet is seeded first, and the output says so: the blog's articles hang on the library's pictures. Removing a module that another seeded module still requires is refused with the command that removes them together.

webx:doctor ​

The last step of the installation and the last step of a deploy, and the same command both times. It repairs nothing: every line says what to run, and the running is left to you. The one thing it writes is a probe file on the library's disk, and it deletes that again.

bash
php artisan webx:doctor
  ✓ Both halves: 8 packages on the server, all of them in resources/js/admin.ts.
  ✓ Bundle: built from what is on disk now — 4 files, every one unchanged.
  ✓ npm halves: 13 packages, each installed at the version the server asks for.
  ✓ Migrations: 39 applied on [mysql], none pending.
  ✓ storage:link: one link, and it leads where it should.
  ✓ Media disk: [public] takes a file and gives it back.
  ✓ Layout: <x-layout> takes both what is pushed and what is passed.
  ✓ Languages: en, ru — the table and the fallback agree.
  ✓ Panel words: the dictionary answers in en, ru, uk.
  ✓ Panel: answers at /cms.
  ✓ config cache: newer than everything it was built from.
  ✓ route cache: newer than everything it was built from.
  ✓ Rate limits: 2 named limiters, all declared.
  ✓ Agent access: Passport has its keys and its tables — an agent can connect.

   INFO  Everything this site needs is in place.

A refusal reads the same way, with the command that mends it in the sentence:

  ✗ Layout: resources/views/components/layout.blade.php has no @stack('head') — metatags pushed
    by a block or a partial are dropped, and the page still looks finished. Add it inside <head>.
  ✗ npm halves: package.json asks for @webx-ui/module-pages@^0.2.0; the installed Composer half
    needs ^0.3.10 — run `npm install @webx-ui/module-pages@^0.3.10`.

What it looks at is a list of things that have each cost somebody a day, and what they have in common is that none of them announce themselves:

  • Both halves. A Composer package installed and never wired in is a section the server answers for and nobody can reach; a call left in the entry file whose package is gone is a section the bundle draws and the server 404s.
  • The bundle. Built from the files on disk now — the commonest failure of the three, and the one that looks most like the change simply not working. The skeleton's vite.config.js records every file of the site the build read, with its hash, in webx-sources.json beside the manifest, and the check hashes them again: an edit to a file the entry imports counts, and a build stage Docker took from its cache does not look stale beside sources copied a minute ago. A site whose vite.config.js predates that plugin is compared by modification time — copy the sources() plugin from the skeleton to stop that.
  • npm ranges. Below 1.0 the caret pins the minor, so a site asking ^0.18.0 never reaches the 0.19 the module needs. That shows up as [MISSING_EXPORT] in a build, or as a component that is quietly missing at runtime.
  • Migrations. Laravel sorts every package's migrations into one list by file name, so a module installed today brings migrations that sort in among last year's. A migration MariaDB refused is never recorded as run, so it turns up here as pending whatever the deploy log said.
  • Storage. storage:link is one command and is forgotten on every second deploy, and a link whose target has been cleared out looks exactly like one that was never made. The library's disk is asked by writing a file to it, because permissions are the other half of that story.
  • The layout. Above.
  • Languages. webx-localization.locales is only a seed for the locales table; the table is what the site publishes in. A fallback the site does not publish in falls back to nothing.
  • Caches and limits. A configuration cache written before webx:panel added the entry serves a panel with no front end. A throttle:<name> nobody declared is read by Laravel as a number of attempts — zero — so the address answers 429 to everyone, and only ever after route:cache.
  • PUT and DELETE. A web server that refuses every method but GET and POST leaves a panel that reads and cannot save. A warning, with what to switch on — see a host that refuses PUT.
  • Agent access. With webx-ui/mcp installed: Passport's keys and tables. Without the keys the guard cannot be built, so a call with no token answers 500 where it should answer 401.
  • The site password. The gate switched on with nobody allowed in, or switched on in .env under a published config that has no gate block, so it closes nothing. While it is on, the report says so on every run, so a site is not left closed by accident after it opens.

A failure exits non-zero, which is what puts it in a deploy:

bash
php artisan webx:doctor --strict   # a warning about the setup stops the deploy too

Some warnings are about what is in the site rather than how it is built — a shared block no page places, a catalogue property whose code clashes with a column of the exchange, pages that the catalogue's root hides. They are marked (content), counted apart in the closing line, and never stop a deploy, not even a strict one: an editor mends them in the panel, and holding the release back would not.

Others are about nothing that is wrong — a state the site is in on purpose, said so somebody knows: snapshots left in storage/app/snapshots after content was carried over, relations kept for a module that may come back, a site run on sqlite. They are marked (note), counted apart too, and never stop a deploy either. The rule a check follows, and a check of your own should:

LevelWhat it means--strict
okin placepasses
noteworth knowing, nothing to do for the site to workpasses
contentmended in the panel by an editorpasses
warnthe setup needs somebody to act — the deploy should waitstops
failsomething the site needs is not in placestops

Diagnosis::note(), ::content(), ::warn() and ::fail() make each; when in doubt between a note and a warning, ask whether a deploy should stop until somebody acts.

A platform that decides by the report rather than reads it asks for JSON — the exit code is the same:

bash
php artisan webx:doctor --strict --json
json
{
  "ok": true,
  "checks": [
    { "state": "warn", "level": "content", "subject": "Regions", "detail": "…", "content": true },
    { "state": "warn", "level": "note", "subject": "Snapshots", "detail": "…", "content": false }
  ],
  "failed": 0,
  "warned": 0,
  "content": 1,
  "notes": 1
}

ok is the exit code said in the report. level names each line as in the table above; state stays warn for all three kinds of warning, for a reader that knows only ok / warn / fail. warned counts the warnings about the setup, content and notes the rest, never one line twice, so failed + warned > 0 is exactly what --strict stops on.

What the skeleton gives you, and what to delete ​

theme/                             # the site's own theme, over webx-ui/theme-default
├── theme.json                     # which theme it stands on
├── tokens.json                    # empty: the site's colours and fonts go here
├── src/css/theme.css              # empty: the rest of the design
├── src/js/theme.js
└── tests/ThemeTest.php            # run by `php artisan test`
resources/views/
├── regions/region.blade.php       # the region tag on a site without module-blocks
└── demo.blade.php                 # "the modules are standing" — delete it first
routes/web.php                     # empty, and meant to stay that way

theme/ is written by webx:setup, not shipped: the layout, the header and the footer come from the theme it stands on (see Where the styles live). With --no-theme there is no theme/, and webx:setup writes layout, header and footer into resources/views/components/ and an empty resources/css/app.css instead.

routes/web.php being empty is the point, not laziness. Every public address of this site comes out of the address registry and is answered by Route::fallback(). A route declared here wins over the fallback for good, so it takes an address the panel can then never hand out — and / costs the most, because the home page of the tree is an empty slug formatted to /. A Route::get('/') there is a home page the panel cannot even save.

The one exception is written for you: on a site without module-pages, webx:setup puts the demo page on / behind a marker of its own, and takes the line back out the day you install module-pages.

Containers ​

The skeleton carries a Dockerfile, two compose files and a docker/ directory, and they are yours like everything else here — delete them on a site that deploys some other way. A machine with nothing but Docker on it — no PHP to run create-project and webx:setup — starts from Docker from zero, which does both inside containers.

bash
docker compose up -d --build                                        # the production shape
docker compose -f docker-compose.yml -f docker-compose.dev.yml up   # somewhere to work

The first builds one image — Composer dependencies, the front end built by the site's own Vite, then php-fpm, nginx, a queue worker and the scheduler under supervisor — and starts it beside a MariaDB. Nothing terminates TLS: the container listens on ${APP_BIND}:${APP_PORT} and a proxy in front of it holds the certificate. The site believes that proxy's X-Forwarded-* — https and the visitor's address — only from loopback and the private networks (config/trustedproxy.php), which is where the proxy of a hosting or of a home server stands. A proxy on a public address, a CDN, is named in TRUSTED_PROXIES in .env, comma-separated, and that list replaces the default; * believes any visitor, and webx:doctor warns about it. The second mounts the checkout into the same image, adds the Vite dev server and a Mailpit, and caches nothing, so a machine with no PHP and no Node on it is still a machine you can work on.

Both read the .env you already have. The variables the containers add are at the bottom of .env.example: where to bind, which port, the database root password, and the first administrator.

webx:boot ​

What a container does between starting and serving is one command:

bash
php artisan webx:boot --pretend    # the list, without running any of it

It waits for the database, migrates, links storage, writes Passport's keys if they are not there yet, seeds the languages and clears the dictionary the release before it built, creates the first administrator out of the environment, tops up the ready-made roles with the permissions of modules installed after them, and caches configuration, routes, views and events. Which of those a site needs it works out from what is installed, so a module added six months from now brings its step with it — and --no-cache is the development container's whole difference.

It never writes a block type, even when the repository carries resources/blocks/: a deploy that did would overwrite what the client changed in the panel. Types reach a site by php artisan webx:blocks:import (--theme for the theme's), run once by a person — see Blocks › Export and import.

Every step is idempotent, because every boot runs all of them. The one thing it will not do is publish Passport's migrations: the copy is stamped with the minute it was made, so a boot that published would hand each container a migration under a name the migrations table has never seen, and the second deployment would stop on table oauth_auth_codes already exists. Publishing is webx:setup's, once, into the repository.

Two things worth knowing before the second deploy ​

Storage is one volume, not two. Uploads, the nightly dumps and the logs are the obvious part; Passport writes its keys a level above them, in storage/ itself. A volume around storage/app alone leaves the keys inside the image, and then an upgrade hands out new ones and disconnects every agent that was connected, with nothing anywhere saying why.

A volume takes its contents from the image once — the first time it is used, and never again. So the entrypoint creates the directories under storage on every boot: a release that starts writing somewhere new finds nothing there on a site that has been running since before it, and what fails is whatever first tried to write.

A host that refuses PUT ​

Some shared hosts let only GET and POST through to PHP: mod_security rules on cPanel, a <LimitExcept GET POST> in the Apache config. The web server answers everything else itself, before Laravel sees the request.

The symptom is a panel that opens, signs in and shows every list, and then cannot save or delete anything: each save answers 403 or 405, which looks like a missing permission. The browser's network tab shows the PUT, PATCH or DELETE request refused, often with the web server's own HTML page as the body rather than the panel's JSON.

The cure is on the front end. methodOverride in createAdmin(), in the panel's entry file (resources/js/admin.ts in the skeleton), sends those requests as POST and names the real method alongside it:

ts
createAdmin({
  // …
  methodOverride: true,
})
ValueWhat is sent
false (default)the method as it is
true, 'header'POST with the header X-HTTP-Method-Override: PUT
'field'POST with _method: "PUT" in the body — for a host whose WAF strips the header too

Laravel reads both on a POST and routes the request as if it had come with its real method; the CSRF check works as before, and the server side needs nothing. Rebuild the bundle after the change. Start with true; if saving still fails with the same refusal, use 'field'.

To check, run php artisan webx:doctor on the server. It sends a PUT and a DELETE to /api/cms/method-probe at APP_URL, an address that answers with a mark only Laravel puts on and changes nothing. A refusal without the mark came from the web server:

  ! PUT and DELETE: the web server refuses PUT 405, DELETE 405 before Laravel sees the request,
    so the panel reads but cannot save or delete — pass `methodOverride: true` to createAdmin() …

It is a warning rather than a failure, because the site itself is fine. Once the entry file asks for the override, the same line turns into a tick. When the server cannot reach its own APP_URL — a container that does not know its public name, a certificate it does not trust — the check says it was not run, and stops nothing.

A password over the site while it is tested ​

A site usually spends a month on its real domain before it opens, seen by the client and the people building it and by nobody else. Two lines in .env close it with HTTP Basic:

ini
WEBX_SITE_GATE=true
WEBX_SITE_GATE_USERS="client:a-long-secret,tester:another-one"

Then php artisan config:cache, if the site caches its config. Nothing has to be deployed: the gate is always installed and reads the switch on every request.

Everything the site answers asks for a pair, including addresses that do not exist. A gate that let 404s through would tell a stranger which addresses do. What stays open is what the panel and the people working in it need:

  • the panel and its JSON (/cms, /api/cms), which have a sign-in of their own;
  • the MCP server and its OAuth endpoints (/oauth/*), because an agent cannot answer a password dialog;
  • a block preview carrying a valid token, the block editor's stage and the block bundles, so the preview frame works for an editor who never typed the site's pair;
  • /.well-known/*, so the certificate keeps renewing.

An administrator signed in to the panel passes without the pair, on every address: the page preview and whatever it loads, a link followed inside it and “Open on the site” all reach the site with the panel's session. The gate keeps strangers out; somebody who can edit the site is not one. A switched-off account does not pass, and what a signed-in administrator gets is sent private with Vary: Cookie, so a shared cache cannot hand it on.

To open anything else, list masks in config/webx-admin.php. They are matched with Str::is against the path without its leading slash:

php
'gate' => [
    'enabled' => env('WEBX_SITE_GATE', false),
    'users' => env('WEBX_SITE_GATE_USERS', ''),
    'except' => ['promo', 'promo/*'],
],

A few things to know about the gate:

  • The pairs live in the environment and nowhere else. A switch in the panel could be flipped by the same person it locks out. A password may contain a colon but not a comma, which separates the pairs.
  • Switched on with no pairs, it lets nobody in. webx:doctor fails on that.
  • Files the web server serves itself are not behind it. /storage and /build never reach PHP, so a direct link to an uploaded file still works. Closing those as well is a rule on the proxy, and that rule then has to repeat the openings above.
  • A config published before the gate has no gate block, and WEBX_SITE_GATE then closes nothing. webx:doctor fails on that as well; copy the block from the package's config.

When the site opens, remove the two lines or set WEBX_SITE_GATE=false.

Where to go next ​

Released under the MIT License.