21 October 2026¶
Fifty entries today:
schedule:listsees theworkprocess that is runningworkno longer skips a minutebroadcast:serveholds its lock, so the orchestrator stops restarting it- A page signed in by an application's own login gets the realtime connection
queue:processwrites a line per task into a file instead of its dashboard- A link can open its page in a dialog —
data-pf-modal - The services screen is the state of the workers; a pool's settings have a page of their own
- A slow command no longer holds the schedule, and every run is recorded
- A page opened in a dialog shows its content, centred
data-confirmon a form asks when it is sent, not at every click inside it- Every tab of a site shares one realtime connection
./dockertestopens the coverage report only when asked- A shared realtime connection that cannot start leaves each page its own, and says what it is doing
- A page can offer its forms to an AI agent in the browser (WebMCP), and the debug bar shows them
- The schedule has a screen: what runs, when, how it went, and what is late
- The admin and the debug toolbar offer tools to an AI agent in the browser
- The notification bell links to every notification, and a message's subject is its title
- Enabling the debug toolbar returns to the page it was opened from
- A notification arriving changes the tab's title, can play a sound, and tells the page
- A rich-text editor: pictures with captions, uploads, a link search, and plugins
- The rich-text editor in a Svelte SPA
- The rich-text editor can float a toolbar over the block at the cursor
- An editor whose textarea sits in its label is drawn after the label
- The rich editor takes a title into its column, and its packages go beside the project's own
- The mail log records the account each message was for
- The tab's title flashes only for a notification that is news
- The rich editor's buttons have words of their own
- The query string reaches
$_REQUESTbehind a rewrite without QSA - A message's text part reads as a message
- Message forms have saved messages, placeholders, writing with AI and a signature
- An email's preview opens its links and does not count as an open
- The message composer asks in its own panels, and a message to one person inserts the person's name
- Events that run in the queue
workloses no minute to a restart- The outbox deadline sweep gets an index
- Domain events as classes
- Scheduled tasks an operator may adjust
- Audiences: attributes of a person, and filters over them
- Segments: named audiences kept up to date
- Group membership through one path, with events
- Consent lists: what a send asks before it reaches somebody
- An audience builder and picker with a live count
- The Segments and Consent lists screens, and segments on the person card
- A mass message's audience from segments, groups, a filter and a consent list
- What people did: person events, recorded from event classes, pages, email and the past
- Audiences do nothing until their tables exist
- Rules: when, who, what, until — run in the queue
- Trying a rule before it runs
- Messages on the site: banners, popups and cards
- The Rules screen: the list, the editor, a rule's boxes and its tools
schedule:list sees the work process that is running¶
Fixed¶
- "No
workprocess is running" beside one that was, executing tasks.schedule:listbuilt itsWorkerLockfrom the namepramnos-work.lock, and the lock's default path adds.lock, so it readvar/pramnos-work.lock.lock— a file nothing writes. It asksWork::lockFilePath(), the path the worker itself uses, supervisor's override included. Its test had taken the lock the same wrong way, which is why it passed (FW-208).
work no longer skips a minute¶
Fixed¶
- A task due in a minute no pass looked at did not run. Each pass asked about the current
minute only, ran what was due one task after another, then slept sixty seconds from when it
ended. A nine-second mail flush was enough: passes at 03:19:59 and 03:21:09, nothing asked about
03:20, and a task on
5,20,35,50did not run — a weekly one would have waited a week. A pass now asks about every minute since the last one, at mostWork::CATCH_UP_MINUTES(15) back, runs each due task once, and sleeps to the next minute's start (FW-210). --intervalbelow sixty ran an every-minute task twice a minute. A minute already asked about is not asked again.
broadcast:serve holds its lock, so the orchestrator stops restarting it¶
Fixed¶
- The orchestrator restarted the WebSocket server every cycle, dropping every open page:
its entry declares
var/pramnos-broadcast-serve.lock, andbroadcast:servenever took a lock or wrote a heartbeat, so it stayed "started-unverified" and was gracefully stopped to be started again. The Services screen, reading the same absent file, showed the running server as stopped and offered to start a second. It takes the lock, heartbeats from the event loop every ten seconds, and releases it on exit; a second one refuses while the first holds it (FW-209). An application that set'requireLockFile' => falseon the entry to get by can remove it.
A page signed in by an application's own login gets the realtime connection¶
Fixed¶
<meta name="pf-realtime">was written only for a session holding a framework token. An application whose login is its own setsuidandloggedand nousertoken, so none of its signed-in pages connected and the notification bell never went live. The meta now followsSession::staticIsLogged()as well — the testgetCurrentUser(), and so/broadcasting/auth, already made (FW-211).
queue:process writes a line per task into a file instead of its dashboard¶
Fixed¶
- A worker run into a file painted its whole dashboard there, five times a second. Only a
supervised worker skipped it; one started by hand with
>> var/logs/ask-worker.logand--sleep=2wrote borders, escape sequences and "Press Ctrl+C to exit" until the file was 542 MB, the task results lost inside it. The dashboard is painted only on a terminal; otherwise each task is one plain line — time, id, type, status, duration, message — and an empty pass writes nothing (FW-214).
A link can open its page in a dialog — data-pf-modal¶
Added¶
data-pf-modalon a link opens its page in a<dialog>over the current one: fetched withX-Pf-Fragment: 1, closed by Escape, the × or the backdrop, focus back on the link afterwards. Without<dialog>, on a modified click or a failed fetch it is an ordinary link (FW-213).Document::isFragmentRequest(), and the request with that header gets therawdocument, so any screen answers with its content alone — no per-screen code. The page cache keys the fragment apart from the page, andBreadcrumb::admin()draws no trail into it.- The queue screen's "Statistics — last 48 hours" and each task's id open in the dialog instead of leaving the list.
The services screen is the state of the workers; a pool's settings have a page of their own¶
Changed¶
/admin/Servicesshows the workers' state and nothing to configure, in reading order: the supervisor, the services (with Restart all), the worker pools — their task types, workers, backlog, load, decision, state, Stop / Start — and the batches. The pool forms that sat above the services, so that seeing whether anything was running meant scrolling past settings, are gone from it; Configure pools links to them.- Every label on the screen goes through
t(), with the Greek in the framework's catalogue, and the ten-second refresh writes its sentences in the page's language. Restart all asks throughdata-confirminstead of an inlineonsubmit.
Added¶
/admin/Services/pools(ServicesController::pools()), linked from the services screen and from Settings: a card per pool. Task types are boxes to tick, from those with a handler (the framework's,queue.handlers, the queue's task classes), those in the queue table and those a pool names, with a field for any other. Every limit has a label in words and a line under it saying what it does — "Always keep at least this many workers running", "Start another worker when more than this many tasks are waiting". A code pool shows its declared values as placeholders and keeps them for a blank field; nothing ticked keeps its declared types. Save, Stop / Start, and Reset to code or Remove (asked first) on each card, and a last card to add a pool.poolsaveacceptstypes[]andtypes_otherbeside the plaintypesstring, so a published form of an application's own keeps working. Save and Remove return to the pools page; Stop and Start return to the screen they were pressed on (back=pools).
A slow command no longer holds the schedule, and every run is recorded¶
Added¶
ScheduledTask::runInBackground()for command tasks: started detached through the hiddenschedule:background, which holds the overlap lock until the command exits and records its end — the pass does not wait. A four-minute command due every minute of an hour had heldworkfor the whole hour on one installation; the tasks due at :15 and :20 behind it never ran, and the catch-up gave up after fifteen minutes (FW-216).- The run history,
scheduleruns(migration2026_10_21_000001): every run, whoever started it, with its duration, result, exit status and the end of its output;RunLogto read it, and to ask for a run that the next pass takes once. Runs older thanschedule.history_days(14) are removed (FW-212). CronExpression::nextRunAfter(),previousRunBefore()anddescribe()— a task's next run, the last one it was due, and the expression in words.
A page opened in a dialog shows its content, centred¶
Fixed¶
- Every
data-pf-modaldialog opened empty. A fragment request was given therawdocument, and a controller takes its view's type from the document, so each screen looked forhistory.raw.php, found nothing and answered 200 with no body. The document stayshtmland leaves only the theme and the head out (FW-213). - The dialog sat in the top-left corner under Tailwind, whose preflight sets every margin to
0 and so cancels the browser's own centring.
.pf-modalputsmargin: autoback in every theme.
data-confirm on a form asks when it is sent, not at every click inside it¶
Fixed¶
- A form carrying
data-confirmasked at every click inside it — on a field, a checkbox, a label, the empty space — because the click handler matched the nearest[data-confirm], the form. Cancel then prevented the click, so a box could not even be ticked. A form's question is asked once, onsubmit, and not when the button that sent it has its own (FW-217).
Every tab of a site shares one realtime connection¶
Changed¶
window.pfRealtimeruns over a SharedWorker where the browser has one. N tabs made N sockets and N/broadcasting/authcalls, and every page change closed one socket and opened another.pf-utils.jsnow runs itself as the shared workerpf-realtime, which holds one connection for every tab: tabs subscribe through it, a private channel is signed by a tab that listens on it (the next one if that tab leaves first), events go to every tab on the channel, a channel no tab wants is left, and after a reconnect each tab gets onepf:notifications:refresh. WithoutSharedWorkereach page keeps its own connection. The API is unchanged;pfRealtime.sharedsays which way a page is connected (FW-215).
./dockertest opens the coverage report only when asked¶
Changed¶
- The framework's
./dockertest --coverageno longer opens the report in a browser unless given--browser. Most runs are made by scripts and tools, and each opened a tab.--nobrowseris still accepted and is now the default.
A shared realtime connection that cannot start leaves each page its own, and says what it is doing¶
Fixed¶
- A page whose shared worker did not start had no connection at all. The worker now answers a
tab's
helloat once; without that answer in five seconds, or on the worker'serror, the page opens a connection of its own with the channels it asked for (FW-215).
Added¶
window.pfRealtime.status(), in every tab: the socket's state and id, the channels the server confirmed, the last error — a refused signature, apusher:error— whether the connection is shared, and why it is not where it tried to be. The worker sends its state to every tab as it changes.
A page can offer its forms to an AI agent in the browser (WebMCP), and the debug bar shows them¶
Added¶
data-pf-toolon a form makes it a tool an AI agent in the visitor's browser can call, registered bypf-utils.jswith WebMCP (document.modelContext.registerTool(), a W3C Community Group draft). The input schema comes from the fields and their labels; a call fills the form and sends it as the page would — current CSRF token,X-Pf-Fragment: 1— and answers with the resulting page's text. A POST form is marked consequential.window.pfTools:list(),call(),calls(),register()for a tool written in script,supported;pf:tool:callandpf:tools:changeevents. There with or without WebMCP.- The debug toolbar's Tools tab, on a page that offers tools: each with its schema and a Run that calls it as an agent would, and every call — an agent's or the panel's — with its input, result and time.
The schedule has a screen: what runs, when, how it went, and what is late¶
Added¶
/admin/Scheduler(System → Scheduler), the framework's own controller: every task with its expression in words, its next run, its last run — when, how long, how it ended — and whether it runs without overlap, on one server, in process or in the background. A task is marked late when the last minute its expression asked for has no run since, failed when its last run ended in an error, and running while a run is in progress. A note says when noworkprocess holds its lock (FW-212).- A page per task, opened in a dialog from the list: its runs, newest first, each with its exit status, host and output.
- Run now: a POST, audited, that asks the next pass of
workorschedule:runto run the task once; nothing runs in the request. - A dashboard card with the late, failed and running counts, and
Pramnos\Scheduling\ScheduleStatus, which the screen and the card both read.
The admin and the debug toolbar offer tools to an AI agent in the browser¶
Added¶
site_searchwherever the admin's search box is: the omnibox's own search, by kind.- Admin forms as tools:
search_help,list_api_tokens,list_token_actions,find_list_subscribers, andrun_scheduled_task(consequential) on the scheduler screen — whose Run now also takes the task as ataskfield. - The debug toolbar's data as read-only tools:
debug_requests,debug_request(one request's SQL, timings, logs, exceptions — masked) anddebug_client_errors, offered throughpfToolswherepf-utils.jsis on the page and straight to the browser where it is not. - A GET form tool replaces the address's query with its fields, as the browser does.
The notification bell links to every notification, and a message's subject is its title¶
Added¶
NotificationBell::render(['all' => $url, 'allLabel' => 'See all']): a link at the foot of the panel to the page of every notification, under the list and when it is empty. None withoutall(FW-218).
Fixed¶
- A
Notification\Messagereached the feed with no title. It writes its title assubject;Feedread onlytitle. An item's title istitle, elsesubject.
Enabling the debug toolbar returns to the page it was opened from¶
Fixed¶
/debugbarsent you to the site root after enabling, unless it was opened from the DevPanel's switch. The page you came from was read only at the POST, whose referrer is the/debugbarscreen itself, so nothing had been remembered. The screen remembers it when it opens (FW-220).
A notification arriving changes the tab's title, can play a sound, and tells the page¶
Added¶
pf:notificationon the document when one arrives over the realtime connection, with itsid,type,titleanddata, read from the feed with the page's session — nothing beyond the id travels over the socket (FW-219).- The tab's title shows "(N) " while N are unread and, on an arrival, alternates with its
title until the tab is looked at. On by default;
NotificationBell::render(['titleFlash' => false])turns it off. 'sound':truefor a short tone made in the page, or a sound's address. Played once per browser — the shared connection's worker names one tab, the visible one, else the last seen. Off by default.
A rich-text editor: pictures with captions, uploads, a link search, and plugins¶
Added¶
'messaging' => ['editor' => 'rich']andMessageEditor::RICH:pf-rich-editor.js, TipTap bundled into one file that ships with the otherpf-*.js. Pictures as<figure><img alt><figcaption>, uploaded by drop, paste or choice tomessaging.rich.upload; a link panel that searchesmessaging.rich.links; the selection kept while a panel has the focus; blocks moved with Alt+↑/↓; embed previews. The controls' words go throught().- Plugins:
pfRichEditor.use({ name, extensions(tiptap), buttons, setup(ui) })adds a site's own marks, nodes, buttons and panels with the toolkit the editor runs on, from a plain script;pfRichEditor.embeds.add({ name, match })adds an embed provider, asked before YouTube and Vimeo. A field picks its plugins withdata-pf-rich-plugins. pfRichEditor.plain(textarea, on)for a text-only channel, as the mass-message form uses for push. The Rich Text Editor guide has the rest.- The sources are in
resources/js/rich-editor/, built bynpm run build:editor; a test fails when the shipped file is older than its sources.
The rich-text editor in a Svelte SPA¶
Added¶
project:resync --rich-editorfor a Svelte SPA: the editor's core and stylesheet inlib/rich-editor/— the same sources the server-rendered bundle is built from, refreshed by every resync after — acomponents/RichEditor.sveltethat is the project's, and the TipTap packages inpackage.json, a version the project chose kept. One editor for server pages and SPA screens, so a fix lands in both.
The rich-text editor can float a toolbar over the block at the cursor¶
Added¶
- A block toolbar, chosen when the editor is set up: over the block at the cursor while the
text has the focus — move it, paragraph or heading, bold, italic, link — with a plugin's
block: truebuttons. Off by default;messaging.rich.block_toolbar, the field'sdata-pf-rich-block-toolbar, orblockToolbarincreate()and the Svelte component.
An editor whose textarea sits in its label is drawn after the label¶
Fixed¶
- Typing into a builtin editor turned on bold when its textarea was written inside its label
(
<label>Text <textarea></label>): the toolbar and the text were drawn inside the label, and a browser sends every click in a label to its first labelable element — the B button. Both editors, builtin and rich, are drawn after such a label; a click on its words puts the cursor in the text, and the builtin editor's text is named by the label's words alone (FW-221).
The rich editor takes a title into its column, and its packages go beside the project's own¶
Added¶
headerincreateRichEditor()— anddata-pf-rich-header="#title"on a field — moves an element into the text's column, under the toolbar, as a page of an article has its title;destroy()puts it back. The Svelte component'sheadersnippet goes there now, instead of above the toolbar.
Fixed¶
project:resync --rich-editorput@tiptap/pmindevDependenciesbeside a project's@tiptap/coreindependencies. The packages go where the project keeps its other TipTap ones, else indevDependencies.
The mail log records the account each message was for¶
Added¶
mails.userid(migration2026_10_21_000002_add_userid_to_mails, with an index(userid, date)), written byEmail::forUser($id)and by a notification's mail for its notifiable. The account's page and the history's?user=list an account's mail whatever address it went to, so an address change no longer hides what was sent before it; the history names the account. Without the column everything matches by address, as before: a log too large to index now can exclude the migration (FW-222).Pramnos\Email\MailLog:recordsUser()andofUser(), the one place that decides how an account's mail is found.
Fixed¶
- "← Back" on a message always went to the whole history. It returns to the page it was opened from — the account's page, a filtered list — when that is one of the site's.
The tab's title flashes only for a notification that is news¶
Fixed¶
- The title flashed "(0) Security change…" with nothing new in the bell. Every
notification.createdflashed the title and played the sound — one written already read, for the record, too, and one read in another tab first — and an id not among the newest fell back to the first item. Both now need the notification found by its id and unread, and never a "(0) " prefix;pf:notificationstill goes out, withread(FW-223).
The rich editor's buttons have words of their own¶
Fixed¶
- The bulleted-list button read "• list" — the last word of its title, which in Greek became
"• κουκκίδες" — and could not be changed without changing its accessible name. Its visible words
are a label of their own,
bulletListShort, as is the picture-by-address button's,pictureUrlShort; both go throught().
The query string reaches $_REQUEST behind a rewrite without QSA¶
Fixed¶
- An unsubscribe link said "This link is not valid", and one-click POST answered 400, on a
site that routes through
RewriteRule ^(.*)$ index.php?r=$1withoutQSA: the query was only inREQUEST_URI,Requestparsed it into$_GETbut not into$_REQUEST, and the token is read withget('u', '', 'request'). WhatRequestparses from the query reaches$_REQUESTtoo, where it has no such key — a POSTed value still wins (FW-225).
A message's text part reads as a message¶
Added¶
Email::setTextBody(): the text part as the application writes it — the message and a short footer — instead of the whole template converted. Sent as written, queued or not, with the unsubscribe link added when the message offers one and the text lacks it. Kept inside the stored body (TextPart), not in a column — compressed and shared as bodies are — and shown on the message's page as the text that went; a resend sends it as the text part again (FW-226).data-pf-plain="omit"in a template leaves its decoration out of the text.
Fixed¶
- The converted text part of a designed template was a mess — and it is what recipients get:
- a logo or an icon link became a bare address on a line of its own; it is left out, or reads
[alt] <url>; - a menu ran together and wrapped between a link's words and its address; a run of links is one a line, never split;
- a layout table without
role="presentation"had its cells glued with|; cells holding paragraphs or pictures mark it as layout.
Message forms have saved messages, placeholders, writing with AI and a signature¶
Added¶
Html\MessageComposer, a toolbar above a message form, wired bypf-utils.jsthroughdata-pf-composerwith whichever editor the body is in — builtin, rich, TinyMCE or none. The framework's "Message one user" and mass-message forms carry it (FW-224).- Saved messages (
savedmessages, migration2026_10_21_000003_create_savedmessages_table): a list that fills the subject and the body, "Save this message" for the account or — withmessages.sharewhere the application defines it — for everybody, by scope. Placeholders stay as written. - Placeholders:
data-pf-editor-fieldsdraws a button per{token}, inserted at the cursor of the subject or the body. The framework's forms fill{firstname},{lastname},{full_name},{username}and{email}for each recipient when they send (MessageComposer::fill(),valuesFor()); a mailing list's subscriber gets its address and the rest empty. - Write with AI through the configured language model: from an instruction, or the text
improved; every placeholder kept, in the form's language when given; logged and costed as
compose; the body cleaned. - A signature per account (
usersettingsmail.signature), edited atComposer/signature; "With my signature" adds it under the body when the form is sent, once. window.PfEditor.getHTML(),setHTML()andinsert(), so another script can work with the builtin editor.
An email's preview opens its links and does not count as an open¶
Fixed¶
- A link in a message's preview did nothing: the frame was sandboxed without popups. It allows them now, scripts still off, and the preview opens every link in a window of its own (FW-228).
- Looking at a tracked message counted as an open: the preview drew the tracking pixel. It
is left out of what the frame draws (
MessageReport::preview()); the report still reads the body as it was kept.
Added¶
data-pf-refresh-on-returninpf-utils.js: an element with an id is fetched again, as a fragment, when the reader comes back to the page. The message page's Tracking tab carries it for a tracked message, so a click made from the preview shows on return.
The message composer asks in its own panels, and a message to one person inserts the person's name¶
Changed¶
- The fields are one "Fields ▾" dropdown instead of a row of buttons;
MessageComposer::toolbar(['fields' => 'buttons'])keeps the row (FW-229). - No browser dialogs.
- "Write with AI…" opens a panel under the toolbar: what to say, Write or Improve, disabled while it writes, and Undo to put back the subject and the body it replaced.
- "Save this message" opens a name field with "For everybody" where the account may share.
- "Forget" asks by a second press.
- Errors go to the toolbar's status line.
- The saved messages' list is hidden while it is empty, and appears after the first save.
- The toolbar's controls carry the
scaffold_theme's button and field classes beside theirpf-composer-*hooks, and each theme'scomponents.csslays them out.
Added¶
- A field may carry a
value. The dropdown inserts the value instead of the token, so a message to one person reads "Cathy". MessageComposer::fields($fields, $values)andfieldsJson($fields, $values)build the list.valuesFor()also takes an account's row as an array.- "Message one user" passes its recipient's values; the mass-message form keeps the tokens.
- The dropdown's label is translated under the key
Recipient details(Greek «Στοιχεία»), notFields: an application with its ownFieldstranslation renamed the button. - Escape closes the dropdown.
- Saving a message to one person kept that person's details: "Γεια σου Cathy", put in from the menu, was saved as written and greeted everyone as Cathy. A value from the menu, or from a saved message filled for this recipient, goes back to its token when saved.
Events that run in the queue¶
Added¶
Pramnos\Event\QueuedListener: a listenerEvent::fire()queues instead of calling. A worker runs it with the event's arguments, as a task of typeevent.listenerthat the framework registers itself. Its place infire()'s results is the task id, and it never stops the listeners after it (FW-227).- The task is written in the caller's transaction on the application's connection: a rollback takes it with the change. A transaction on another connection does not cover it.
- Folding by key —
debounceKey(),debounceWindow()(60 s),debounceCeiling()(600 s): events with one key are one waiting task with the last event's arguments, counting the others inabsorbed.QueueManager::addKeyed()does the same for any task. - Work done once per key —
idempotencyKey(),idempotencyTtl()(30 days): a used key queues nothing, and a task that finds it taken completes as skipped. A failed run gives it back. Event::fireAs(?int $userid, …): who fired it, for a cron, a webhook or a worker; null is the system.- Every task carries its event — id, time, who,
source(web, cli, queue),causedBy,rootId,depth— and a chain deeper than ten is refused and logged. The Queue screen shows all of it, with the folding key and the count. - An argument that cannot travel as JSON throws in development and under tests. In production it
is logged to
events, and that task is skipped. - Migrations
2026_10_21_000005_add_event_columns_to_queueitemsand2026_10_21_000006_create_queue_idempotency_table. Without them, queued listeners still queue, unfolded.
work loses no minute to a restart¶
Fixed¶
- A task due while
workrestarted never ran (FW-230). Each pass asks about every minute since the previous pass, but that minute was kept only in memory, so a new process looked at the current minute alone. A deploy restarts the worker, and the old process finishes its pass first, which leaves a gap of one or two minutes. On a day with 21 deploys, a task on18 7 * * *never ran, and it left no row inscheduleruns, not even a skipped one. The last minute asked about is now kept invar/pramnos-work.lock.minute, beside the lock, and a new process starts after it, capped atWork::CATCH_UP_MINUTES(15) as before.
The outbox deadline sweep gets an index¶
Fixed¶
mail:flushread the wholemailstable every minute (FW-231). Its deadline sweep,UPDATE mails … WHERE status = 2 AND date < ?, has nothing to narrow it on amailswith no index onstatus. That is an installation whose table predates the framework's migrations. On 35 million rows it took nine seconds a run, with an empty outbox. Migration2026_10_21_000007_add_status_date_index_to_mailsadds(status, date)where no index leads withstatus, and does nothing where one does: the framework's ownidx_mails_status, or one an installation built itself under any name.
Domain events as classes¶
Added¶
Event::dispatch(object $event): fires the class'sEVENTconstant, or the class name when it has none, with the object as its one argument. A listener in the request receives that instance itself, models included, so what it changes on the caller's objects is what the caller saves next (FW-233).Pramnos\Event\QueueableEvent(jsonSerialize(): array,static fromQueue(array $data): static): an event object that can reach aQueuedListener. The task carries its data and its class, and the worker handshandle()the objectfromQueue()builds. Onehandle(JobPostCreated $event)therefore serves the request and the queue.debounceKey()andidempotencyKey()are asked at dispatch, with the object.- Listener classes typed with their event —
handle(JobPostCreated $event)— registered by class name, are called byfire()without implementingListenerInterface. create:eventwrites the convention:EVENT(JobPostCreated→jobpost.created), a readonly payload, andjsonSerialize()/fromQueue().create:listenertakes--event=for a typedhandle()and--queuedto extendQueuedListener.
Changed¶
QueuedListenerno longer implementsListenerInterface: no typedhandle()could satisfy that interface'shandle(mixed ...$args). It is callable instead, and calling it runs it at once, so an instance can still be registered withEvent::listen(), andfire()still queues it.
Documentation¶
- Queue guide: new section "Domain events as classes", covering when to use a class rather than a bare name, that listeners in the request act on the caller's object, and that a listener which changes other data goes to the queue. Console guide: the new options.
Scheduled tasks an operator may adjust¶
Added¶
->adjustable($id, $label, $group, presets:, limit:)on a scheduled task: from the task's page on the Scheduler screen, an operator can pause it, give it another frequency from a fixed list, keep it to an hours window, and set a per-run limit (FW-232).- The list of frequencies: every 1, 5, 10, 15 or 30 minutes; every 1, 2, 3, 6 or 12 hours; daily at a time; weekly on a day at a time. No free cron.
- The hours window may cross midnight. Outside it, and while paused, the task is not due and writes nothing.
- The limit reaches a command as
--limit=Nand a callable or a job'shandle()as its argument, held to the declared[min, max]. - Applied without a restart. Settings live in
scheduleoverrides(migration2026_10_21_000008_create_scheduleoverrides_table), keyed by the application's id.workandschedule:runread them once a pass. Back to the schedule in the code deletes the row. - Not late.
ScheduleStatustreats a paused task, or one outside its hours, as not late, and lists it as Paused since … or Outside hours · next 09:00. Its next run is the first one inside the window. - Audited. A save is
scheduler.adjustedin the audit log and a reset isscheduler.reset, each with the settings before and after. The task's page says who changed it and when. scheduler.adjust(Scheduler::ADJUST_ABILITY): changes adjustable tasks, and opens the screen to an account that holds it, showing the adjustable tasks only, grouped, with no Run now. Where the application defines no rule for it,admin.scheduleradjusts as well.
Audiences: attributes of a person, and filters over them¶
Added¶
Pramnos\Audience: the first part of segments, rules and A/B tests (FW-234). A person is a row ofusers; an application declares what can be asked about one, and a JSON filter of conditions grouped "all" / "any" chooses people by it.- Attributes (
Attributes::define()): a column ofusers, a column of a one-to-one table (LEFT JOINed once, only when used), a one-to-many table (asked withEXISTS, so counts stay counts of people), or an SQL expression per driver; typed text, number, date, boolean, list or many, with a label, a heading, choices and an ability. The framework declares account type, language, registered, last sign-in, status, email verified and groups.Attributes::scope()says who is a person at all. Filter: checked when made, with the path of what is wrong; compiled to the query builder;count(),sample(),ids()in keyset pages,matches(),breakdown()per value, andexplain()— every condition with ✓/✗ and the person's actual value.
Documentation¶
- New Audience guide.
Segments: named audiences kept up to date¶
Added¶
Pramnos\Audience\Segments(FW-234): a filter with a name, owner and description, andsegment_members— who was in it, one row per stay,entered_attoleft_at(migrationcore/2026_10_21_000009_create_segments_tables).- Recomputed in full by
segments:recompute, which the framework schedules hourly, and per person withrecomputePerson()— in the queue, folded per person, on the events an application names withSegments::refreshOn(). segment.entered/segment.leftas event classes (SegmentEntered,SegmentLeft) on each change. A segment's first recomputation records its members without them.- Filters can name segments (
{"segment": id, "op": "in" | "not_in"}), computed in dependency order. A segment that would name itself is refused, and one in use — by another segment, or by what an application reports throughSegments::usage()— is not deleted. - Audience reads fail loudly:
FilterandSegmentsread withgetAllOrFail(), so a mistyped side table is an error on PostgreSQL too, not an audience of nobody.
Documentation¶
- Audience guide: Segments. Console guide:
segments:recompute.
Group membership through one path, with events¶
Added¶
Pramnos\Audience\Groups(FW-234):add()/remove()for one person or many,addMatching()/removeMatching()for everybody a filter matches, andof()for the person card. Only real changes are written; each flushes theusergroupscache, tells the applications when the group holds a role, and dispatchesgroup.joined/group.left(GroupJoined,GroupLeft) with who did it and from where.
Changed¶
- The Groups screen adds and removes members through
Groups, so its changes are announced too.
Documentation¶
- Audience guide: Groups.
Consent lists: what a send asks before it reaches somebody¶
Added¶
Pramnos\Audience\ConsentLists(FW-234): sends and rules name a list, never a field. The application declares sources (ConsentLists::source(): a filter meaning "agreed", and a column for the default); lists over them have a name, a channel, a description and a default for new accounts (migrationcore/2026_10_21_000010_create_consent_lists_table).narrow()an audience to a list,removes()— how many consent takes out —allows()for one person, andapplyDefaults()at registration. An email list always leaves out addresses that unsubscribed fromall, and from its own opt-out list.email_optoutsattribute: the lists an address left, so a filter can ask about them too. One-to-many attributes take->matching()for a table keyed on something other than userid.
Documentation¶
- Audience guide: Consent lists.
An audience builder and picker with a live count¶
Added¶
Html\AudienceBuilder::filter()(FW-234): conditions over people's attributes in groups of "all" / "any", with the count beside them as they change, a breakdown by any attribute that has one, and a sample linking to each person's card.AudienceBuilder::picker()for a send: in any of these segments and groups, except these, a filter for this send alone, and the consent list — showing what each step takes out.Audience\Pickercompiles the state to one filter:final(),counts()./admin/Audience:preview,explainandcatalog, behind the administration floor; a filter about an attribute the account may not see is refused.pf-audience.js, and thepf-audience-*styles in the three themes.
Fixed¶
- A filter whose group holds only empty groups compiled to
(); empty groups now ask nothing. pramnos-testing:querySelectorAll()no longer hands a text node to the selector matcher, so a script that appends text beside elements can be tested.
Documentation¶
- Audience guide: The builder and the picker.
The Segments and Consent lists screens, and segments on the person card¶
Added¶
- Segments (
/admin/Segments, People → Segments, FW-234): the library of segments and groups — now, +entered / −left over seven days, used by — and a segment's page with its newest members, Work it out now, and Why is this person in or out?. Saving works it out at once; a segment in use is not deleted. - Consent lists (
/admin/ConsentLists, People → Consent lists): every list with its channel, source, default and count. Html\AudiencePanel::person(): segments and groups on a person's card, shown on the Users screen in the three themes.- Changes to segments and lists are in the audit log with before and after.
Documentation¶
- Audience guide: The screens.
A mass message's audience from segments, groups, a filter and a consent list¶
Added¶
- The mass-message compose form carries the audience picker (FW-234): in any of these
segments and groups, except these, a filter for this send, a consent list for the channel —
with the live count and what each step takes out. When anything is picked, the message's
criteria are
['audience' => <state>]and the account criteria are not used. MassMessageAudience::resolve(),preview()anddescribe()read that state throughAudience\Picker; a state that no longer checks reaches nobody, never everybody.
Documentation¶
- Email guide: the mass-message audience. Audience guide: the picker on the compose form.
What people did: person events, recorded from event classes, pages, email and the past¶
Added¶
- Person events (FW-234 part B):
person_events— per person and event, how many, the first and the last — andperson_event_log, each occurrence with its properties (migrationcore/2026_10_21_000011_create_person_events_tables). - No call of its own: an event class implementing
Audience\PersonEventis recorded for its person wheneverEvent::dispatch()fires it, in the queue (RecordPersonEvent), then the person's segments are refreshed, folded.PersonEvents::recordLater()for the rest. - Conditions over them: ever, never, at least / at most N — of all time or in the last M days —
last or first within N days, first or last before / after a day;
explain()gives the count, first, last and the count in the window. The builder lists them under What they did. - The past:
PersonEvents::backfillFrom()from an application's table (Unix or datetime),backfill(),forget(). - Named pages (
PersonEvents::page()), matched on every rendered page for whoever is signed in; email opens and clicks asemail.opened/email.clickedof the account the mail log ties the message to. audience:prune-events, scheduled daily: the log pastaudience.event_log_days(365).
Documentation¶
- Audience guide: What people did. Queue guide: a
PersonEventis recorded on dispatch. Console guide:audience:prune-events.
Audiences do nothing until their tables exist¶
Fixed¶
- An application holding the audience migrations back (
migrations.exclude) is untouched by FW-234: a dispatchedPersonEvent,Segments::refreshOn(), named pages and email opens and clicks queue nothing and raise nothing;segments:recomputeandaudience:prune-eventssucceed with one line; the Segments and Consent lists screens leave the menu and say they are not installed. Before this, a person event queued a task that failed in the worker. Audience\Installedanswers whether the tables are there, remembered five minutes per process.
Documentation¶
- Audience guide: Before the migrations run.
Rules: when, who, what, until — run in the queue¶
Added¶
Pramnos\Audience\Rules(FW-234 part C, first piece): rules as versioned JSON — a trigger (an event, entering or leaving a segment, a schedule, once), who (a filter), steps (actions, waits, waits for an event with a timeout, branches on a filter or on whether a message was opened, clicked or delivered, splits), an exit and a goal; entry first or every (migrationcore/2026_10_21_000012_create_rules_tables).Engine:Engine::listen()in an application's init puts a listener on each event the running rules ask about; every step is a queue task (audience.rule, answered by the framework) that moves a run only from where it is. Sending hours, the rule's consent list and frequency caps (audience.caps) before every message; shadow mode logs what would have been sent.- Actions:
email(a mail template into the outbox, its origin inmails.moduleinfo),bell,push,group_add,group_remove,attribute,notify_admin,webhook;Actions::define()for an application's own. - Audit and run log: every change in the audit log with before and after; every decision of
every run in
rule_log, keptaudience.rule_log_days(90).rules:runevery minute for schedules. A segment or consent list a rule reads is not deleted.
Documentation¶
- Audience guide: Rules. Console guide:
rules:run.
Trying a rule before it runs¶
Added¶
Audience\Rules\Tester(FW-234 C):why()— would this person come in, and what would each step meet;simulate()— how many the last 7 or 30 days would have brought, and the messages per channel;testSend()— a step's action sent to oneself with a real person's details;diff()— who a new version takes in and leaves out.Rules\Contextcarries a recipient for a test send (to(),isTest()); the framework's actions send there and fill in the person's details.
Documentation¶
- Audience guide: Trying a rule before it runs.
Messages on the site: banners, popups and cards¶
Added¶
Audience\SiteMessages(FW-234 C): a banner, popup or card for one person, until closed or expired, its first showing recorded (migrationcore/2026_10_21_000013_create_site_messages_table).Html\SiteMessages::render()in the three scaffolded layouts, for whoever is signed in; the close button is a form to/SiteMessages/dismiss/{id}, back to the page.- The rules'
site_messageaction. Audience\Explained: a condition of an explanation in words — values by their labels, yes or no, days, an event's count and last time. A segment's "why" uses it: an account type reads Professional, not 2.
Documentation¶
- Audience guide: Messages on the site.
The Rules screen: the list, the editor, a rule's boxes and its tools¶
Added¶
/admin/Rules(People → Rules, abilityadmin.rules, FW-234 C): the rules with how each is doing; the editor (Html\RuleEditor,pf-rules.js) — when, who and the exit in the audience builder, the steps as boxes with their branches, goal, consent, hours, caps; a rule's page with its boxes and how many went through each (Html\RuleCanvas), switching, why for an account, had it been on for 7 or 30 days, a test send to oneself, versions compared, and its log.- The
pf-rule-*styles in the three themes. - The Email Log's message page says which rule sent a message — Sent by rule …, version …, step
…, linked — read back with
Rules\Context::parse().
Changed¶
Tester::testSend()refuses a step that is not a message: a group or attribute change tried on oneself would be real.
Documentation¶
- Audience guide: The Rules screen.