Skip to content

Blog ​

@webx-ui/module-blog is the blog as three sections of the panel, and webx-ui/module-blog on the server is what they edit. This page is both, because neither is useful alone.

Very little is written in either half, the same way the pages module is mostly somebody else's. The address is the registry of webx-ui/routing, the body is blocks, the draft and the history are module-admin, what an article says about itself is module-seo, the covers are module-media, the languages are webx-ui/localization.

What this module adds is the three things that make an article an article rather than a page: it has a date, it can be in several rubrics, and it carries tags — which get made by the hundred and have to be raked over afterwards.

Install ​

bash
pnpm add @webx-ui/module-blog
composer require webx-ui/module-blog
php artisan migrate
ts
import { createAdmin } from '@webx-ui/module-admin'
import { blog } from '@webx-ui/module-blog'
import '@webx-ui/module-blog/style.css'

createAdmin({
  modules: [...blog()],
})

blog() returns three modules, not one: the panel's navigation is one entry per module and the blog wants three of them. They arrive under one heading because the server puts all three in the blog group. A section whose server half is not installed never appears, so leaving one out is safe.

Permissions: blog.articles.view opens the list, blog.articles.manage writes, and blog.taxonomy.manage covers rubrics and tags together — somebody who may rename a rubric may rename a tag, and two permissions would be two places to forget.

Addresses ​

Three types in the registry, all under one prefix and all in one flat namespace:

TypeAddress
articleblog/how-to-choose
rubricblog/repairs
tagblog/tag/belts

The prefix is webx-blog.prefix; an empty one puts articles and rubrics at the root of the site beside the pages. Either way the namespace is flat, so a rubric called "Repairs" and an article slugged repairs are one address and the second of them is refused — OnConflict::Fail on all three types. An address chosen deliberately should not quietly become repairs-2.

The rubric is not part of an article's address, and that follows from an article being able to have three of them: "which of the three" has no answer, and any answer would be a hidden main rubric that moved the article when somebody reordered the checkboxes.

Changing the prefix afterwards is one command — and a decision made once:

bash
php artisan webx:routes:rebuild --type=article --type=rubric --type=tag

The paths are recomputed and the old ones stay behind as aliases that redirect.

title, slug and lead are translatable and the address is per language. An article with no slug in a language has no address in it and does not open there: an address built out of another language's slug would serve English words in front of content nobody translated.

The date is the publication ​

There is no is_published column, no queue and no scheduler. published_at is the whole of it: in the future the article is waiting, in the past it sits where that date puts it in the feed, and which of the two it is gets decided at the moment somebody reads it.

php
$article->publish(at: now()->addWeek());   // on the site next Tuesday

$article->isPublished();   // false
$article->isScheduled();   // true
$article->status();        // 'scheduled'

Article::query()->published()->get();   // what a reader can see, right now

Five states and not three: never published · dated ahead and waiting · on the site · on the site with edits waiting · taken off it. The last one is why this is not a boolean — an article that was never published and one that was pulled this morning both have an empty published_at, and only the history tells them apart. "Draft" on something that was live at breakfast is a lie.

To the frame underneath, a scheduled article is already published

published_at is not null, which is all HasDraft looks at, so a general count of live records elsewhere in the panel will include it. Inside this module everything goes through published(). A test that only ever uses now() is green against code that never checks.

Dates travel with an offset — 2026-09-26T09:00:00+03:00 — in both directions. A wall clock with no zone is read by the server in the application's timezone and by the browser in the reader's, and one article ends up listed at one time and edited at another.

Rubrics and tags ​

A rubric is navigation, so instead of a draft it has is_visible. Hidden, it answers 404 and drops out of the menu; its articles go on answering at their own addresses, because they are not its property. A rubric that still holds articles refuses to be deleted, and says how many — a soft-deleted rubric with live articles in it is a hole in the navigation nobody notices.

An article's rubrics are ordered, and the first one is the main one: the breadcrumbs, "more in this rubric" and <category> in the RSS all read it. There is no separate switch for it, because one control is better than two that can disagree.

A tag is a word. It is made from the article form and sorted out later on a screen of its own: renamed, opened to the index, deleted, or merged into another.

php
app(TagMerge::class)->merge([$belt, $driveBelts], $belts, redirect: true);

The articles move over, the duplicates go, and with redirect: true the addresses that existed stay alive as rows in seo_redirects. They have to be redirects rather than aliases: an alias of webx-ui/routing is keyed to the entity and dies with it. The operation is irreversible and the dialog says so — the rows that say which article carried which word are gone.

Tags and the index ​

A tag page has an address, because people follow tags, and by default it is not in the index — a hundred thin listings is how a site teaches a search engine to ignore it. Two things open it, and both stay visible in the panel:

  • clearing noindex on the tag;
  • writing a rule in seo_urls for the tag's address.

The second one is a fact about the rule and not about its contents. A rule that fills in a title and a description and leaves robots empty still means "this page is wanted" — and a merge of fields could never see that, because the empty robots would leave the module's noindex standing underneath it. So the module asks whether a rule matches at all:

php
app(TagIndexing::class)->state($tag);   // 'open' · 'rule' · 'noindex'

Three answers rather than two, so the editor who wrote the rule is not looking at a row that says noindex and disagreeing with it. The tags screen shows the same three.

The panel ​

Articles is a page of twenty with filters over it — rubric, tag, author, state, a search box that looks in every language the site has. Pinned articles carry a mark and are always on top. The table has fixed widths so that the row menu stays a row menu; on a phone it becomes cards.

The editor has four tabs: Content · Settings · SEO · History. The form is a described screen, blog.article-form, registered by the package, so a module or a project adds to it with a patch rather than a fork — the SEO card arrives that way, from module-seo and not from the site.

The settings tab is where the three differences live: the rubrics as a list that is dragged into order, a tag box that offers to make the word it cannot find, and the day and hour the article goes out. The action bar below says which day that is before "Publish" is pressed.

Rubrics, tags, the pin and the related list are not drafted

They are rows in a pivot, not values in a column, and there is no such thing as half a row. Moving an article between rubrics and not publishing has moved it on the site — which is right, the navigation is not a draft, but it is not what a "Save draft" button appears to promise, so the panel says so beside the field.

The panel sends back the revision it read the article at — a short hash of the values and of what is held in the pivots — and a save whose revision is no longer the current one is answered with a 409 carrying the article as it now is. Two writers who saved the same thing did not conflict, and the check says so. The same guard covers an agent.

Rubrics is the whole menu of the site on one screen, dragged into its order, and a page of its own for the one being edited — the panel's shared category screens, with the blog's words on them. The page is the screen blog.category-form, three tabs. Content is the words: the name, the address, whether it is on the site, the introduction — and a card for the fields of the project, drawn only once a patch puts something in it. Image is the cover. SEO is the card, written once and then left to the rules. One Save for all three, and a 422 opens the tab the failing field is on. A field a site patches onto the screen is kept in rubrics.extra and printed with $rubric->extra('name').

Tags is a table with selection, inline renaming, and a selection bar that opens, closes or deletes thirty tags in one gesture — thirty requests for one gesture is thirty chances to do half of it.

GET    /api/cms/blog/articles                      q, rubric, tag, author, status, sort, page
POST   /api/cms/blog/articles                      { title, slug? } — nothing else
GET    /api/cms/blog/articles/{id}                 values, the revision, a preview link
PUT    /api/cms/blog/articles/{id}                 the draft: { values, revision }
POST   /api/cms/blog/articles/{id}/discard         throw away what is waiting
POST   /api/cms/blog/articles/{id}/publish         { at? } · /unpublish
DELETE /api/cms/blog/articles/{id}                 to the bin · POST /restore
GET    /api/cms/blog/articles/{id}/versions        the publications, newest first
POST   /api/cms/blog/articles/{id}/versions/{n}/restore

GET    /api/cms/blog/rubrics                       POST · PUT · DELETE
POST   /api/cms/blog/rubrics/reorder               { ids: [] }

GET    /api/cms/blog/tags                          q, empty, noindex
POST   /api/cms/blog/tags                          PUT · DELETE
POST   /api/cms/blog/tags/merge                    { ids, keep, redirect }
POST   /api/cms/blog/tags/mass                     { ids, action: index|noindex|delete }

The public half ​

Three listings and an article page, plus an RSS. The feed is at {prefix}, a rubric and a tag at their own addresses, and page two of any of them is ?page=2 rather than an address of its own — a listing in the registry would be two hundred rows where one belongs.

php
'views' => [
    'article' => 'blog.article',
    'feed'    => 'blog.feed',
    'rubric'  => 'blog.rubric',
    'tag'     => 'blog.tag',
    'rss'     => 'blog.rss',
],

Until the site has written those, the package's own are used — bare documents, so a fresh installation serves a blog rather than an error. Publish them and rewrite them:

bash
php artisan vendor:publish --tag=webx-blog-views

Keep only the file you change and delete the rest: those go on coming from the package, fresh with every release.

Nobody types a <title> into a view. With an empty SEO card an article, a rubric or a tag names its page itself: the title through the site's title template, the lead as the description, the cover as og:image, above the site's default social image (a tag has only its word). The feed is called by the blog's name the same way — @webxSeo(fallback: ['title' => trans('webx-blog::blog.title')]). A published copy of a view with an @if ($meta->title === null) block is older than this — delete that block and its $seo / $meta lines.

The layout ​

webx-blog.layout names a Blade component for the four pages to stand in; empty means webx-blog::standalone, the bare document the module ships:

php
// config/webx-blog.php
'layout' => 'layout',   // <x-layout>

php artisan webx:panel --sync writes that line itself when the site has resources/views/components/layout.blade.php. The deal is the same for every module — a head slot and the default slot for the content — and it is written out in full under Pages. The RSS has no layout: it is a feed, not a page.

With no prefix the feed route is not registered at all: / belongs to the site, and a list of articles on it is a page the site writes. The RSS still gets an address, because a site without one has nothing to subscribe to.

"Read next" is the articles pinned by hand first, then filled in automatically — most tags in common, then the main rubric, published only, never the article itself. How many is webx-blog.related; zero turns the automatic half off.

The article page is a composition ​

What stands inside the layout on an article is the composition blog.article (Compositions): a tree of nodes with ids whose leaves are the module's views. A theme moves, removes, replaces and adds nodes by id — the cover above the title, no "Read next", its own byline — in theme/compositions/blog.article/default.json, without a copy of article.blade.php.

root                       group
  article                  <article class="webx-blog-article">
    article.header         <header class="webx-blog-article__header">
      article.breadcrumbs  webx-blog::article.breadcrumbs — when webx-blog.breadcrumbs is on
      article.title        webx-blog::article.title — the <h1>
      article.meta         webx-blog::article.meta — the date and the byline
      article.cover        webx-blog::article.cover — when the article has a cover
    article.content        webx-blog::article.content — the blocks          protected
    article.tags           webx-blog::article.tags — the <footer> with tags, when there are any
  article.related          webx-blog::article.related — "Read next", when there is anything
jsonc
// theme/compositions/blog.article/default.json
{
  "add": [{ "id": "lead", "to": "article", "before": "article.content", "view": "blog.lead" }],
  "move": [{ "id": "article.cover", "before": "article.title" }],
  "replace": { "article.meta": { "view": "blog.byline" } },
  "attrs": { "article": { "class": "is-wide" } },
  "remove": ["article.related", "article.breadcrumbs"],
}

blog.lead and blog.byline are views of the theme (theme/views/blog/lead.blade.php); they see what every leaf sees — $article, $related and $content, the base's vars —, and webx:theme:check notes a view of the theme that reads anything else. article.content is protected: the head already carries the styles of exactly these blocks, so it is neither removed nor replaced, and moves only inside article. The article's view includes the composition with the handle default: default.json is the file to write, a blog.article/blog.article.json is never read (a note in webx:theme:check). The layout around the article is patched in theme/compositions/layout/blog.article.json.

A published copy of views/vendor/webx-blog/article.blade.php still wins over the composition, and webx:theme:check says so as a note. To go back to the package's page: php artisan webx:composition:show blog.article --blade prints what the composition gives, the difference with your copy becomes a patch, and the copy is deleted.

Author pages ​

Off by default, the blog names the account that wrote an article — as it always has. A site that wants its authors to be people a reader can look up turns them on:

bash
WEBX_BLOG_AUTHORS=true

The panel then has Blog › Authors, behind its own permission blog.authors.manage: a profile per administrator who writes — the name a reader sees, a role, a photo, a few lines about them and the last part of the address. Making one is choosing the account; the name starts as the account's and is the site's to change.

A page is the author's own decision, «Page on the site» on the profile. With it, the author has a page at blog/author/anna with all of their articles, the byline under every article links there, and the article's BlogPosting names them as a Person with that address. Without it — or on a site with the switch off — the byline is text and the address answers 404. Both halves have to say yes: turning the feature on does not make everybody who writes a public figure.

  • Without a profile an account is named by its own name, as before.
  • With nothing published yet the page exists and stays out of the index (noindex,follow) and out of the sitemap, until the first article goes out.
  • The address stays in the registry whatever the switches say, so opening a page later does not move it. ProfilePage markup with the Person in it is printed on the page.
  • The look is webx-blog::author until the site writes resources/views/blog/author.blade.php (or WEBX_BLOG_VIEW_AUTHOR); the cards in it are the same blog.card as everywhere.
  • Networks — the same «network + address» rows as the site's own in Settings › Contacts: icons on the author's page, and the sameAs of their Person.
  • The markup ties it together: the author of every article and the subject of the page are one Person (@id — the page's address with #person); a BlogPosting names its rubric (articleSection), its tags (keywords), its language and the site's Organization as publisher; the feed is a Blog.
  • In the article editor, under «Author», a link to the author's profile — or «Make a profile» for an account without one.

Latest articles on any page ​

The module offers a block type, Latest articles (blog): a heading, the articles and a link to the feed. Install it once with php artisan webx:blocks:offered --install --module=blog (a theme with a blog block of its own brings that one instead, with webx:blocks:import --theme), then put it on the home page.

  • Which articles — the field «Articles» is a wx-collection of the source blog: all of them or the rubrics you pick, and how many. The order is the feed's: pinned first, then the newest; only what a reader of the feed would see.
  • The cards are the feed's own (webx-blog::partials.cards), so a theme's parts/blog/card.blade.php draws them the same on the home page as in the blog.
  • A block of your own takes the same field and either prints the cards as data ($articles['items']: title, url, lead, cover, published_at, rubric, tags, author) or asks WebxUi\Blog\Rendering\ArticleQuery::block($articles) for the models.

For an agent: MCP ​

The three sections are also a set of tools on the panel's one MCP server at /api/cms/mcp (webx-ui/mcp comes with this module); how an agent connects and acts as an administrator is AI agents.

ToolWhat it does
articles_listThe blog, or a state, a rubric, a tag, an author, a word — or the bin
articles_getOne article in full: the values, the revision, a preview link
articles_createA new article as a draft, with a title and the address made out of it
articles_updateThe values into the draft, guarded by the revision
articles_publishThe draft onto the site under a date · articles_unpublish takes it off
articles_deleteTo the bin, and its address is released
rubrics_listThe sections of the blog, in menu order, with how many articles each holds
tags_listThe tags, most used first, with how each stands with the index
tags_mergeSeveral tags into one — irreversible, optionally leaving redirects behind
authors_listThe author profiles: account, articles, whether the page answers, where
authors_updateThe words of a profile and its «Page on the site» — not making one

Every tool that changes something accepts dry_run: true and then reports what it would do without doing it. An article is named by its id or by its address — "/blog/how-to-choose" — and a rubric or a tag by its id or its slug, because that is what an agent has in its hands. Text fields answer with every language at once: an agent that got one title has no way of knowing whether the others exist.

The body does not travel through these tools. Blocks are blocks_edit_content, which names the node it changes and leaves the rest alone; sending a blocks key to articles_update is refused with that sentence rather than ignored.

There is no rubrics_create and no tags_create, and both absences are deliberate. Deciding the site has a ninth section is a decision about its navigation; inventing a tag while writing one sentence is how a blog ends up holding three spellings of one word. What an agent gets instead is tags_merge, which is the job nobody gets round to.

Before writing, an agent reads blog://feed: the last thirty articles as a reader sees them, with their addresses, rubrics and tags. Half of what it is for is finding out that the blog published exactly this in March; the other half is picking up how the blog writes. One prompt, write_article, packages the loop — read the feed, the rubrics, the tags and the block catalogue, create the article, fill it with blocks_edit_content, settle the settings, look at the preview, write the SEO card, and leave it as a draft.

The prompt tells the agent not to set published_at

In this module the date is the publication. An agent that writes one while filling in the settings has put a half-written article on the site — or scheduled it there — without anything named "publish" being called.

Config ​

config/webx-blog.php:

KeyDefaultWhat it is
prefixblogThe first segment of every blog address
per_page12Articles on a listing page
related3How many "read next" are worked out beyond the pinned
tags.noindextrueWhat a new tag starts with
authorsfalseAuthor profiles and pages (WEBX_BLOG_AUTHORS)
views.*The views each page is printed with
layoutThe Blade component those views stand in
middlewareWhat the feed and the RSS run through

What is deferred ​

  • Comments — a module the size of module-inbox: moderation, spam, notifications.
  • Subscriptions and a newsletter need a scheduler and outgoing mail that actually run.
  • A view counter is a write on every GET, and a conversation about caching.
  • Series, co-authors, authors who are not administrators — an author page is an administrator's profile (Author pages).
  • A tree of rubrics — if it ever turns out to be wanted, nested-set and TreePath are there.
  • Permissions beyond the three above, like everywhere else, wait for the wider conversation about roles.
  • sitemap.xml belongs to module-seo; the blog only hands over its canonical addresses.

Released under the MIT License.