Calling PHP from the frontend
The frontend calls PHP through bound services: PHP objects whose public
methods pages can call, and which may stream values back through
channels. PHP reaches pages with events. myphp generate turns
them into a TypeScript client, so calls, channels and events are checked by
the compiler on the page's side and documented in your editor.
This is how web pages reach PHP. Native UI is PHP already:
its view calls your code directly, and needs none of this.
Bind a service
Pass an object to Ipc::bind() before App::run():
/** Stores the user's notes. */
final class Notes
{
/** @var list<Note> */
private array $items = [];
/** Creates a note and returns it. */
public function add(string $title): Note
{
if ($title === '') {
throw new InvalidArgumentException('a note needs a title');
}
return $this->items[] = new Note(count($this->items) + 1, $title);
}
/** All notes. @return list<Note> */
public function list(): array
{
return $this->items;
}
}
/** A note. */
final class Note
{
public function __construct(public int $id, public string $title, public ?string $body = null) {}
}
Ipc::bind(new Notes());
The service is named after its class, Notes, and every public method of
the object is callable, here as Notes.add and Notes.list.
Ipc::bind($object, 'Store') names it explicitly, for classes whose names
collide.
A method may:
- take any number of arguments that JSON carries, after an optional
CallContext;
- take
Channelparameters, to stream values to the caller while it runs
(see channels);
- return nothing, a value, or a
Generator, which streams; - throw, which rejects the page's promise.
Calls run on the main thread, one at a time, in the order they arrive: a
method that waits (a dialog, App::wait(), a download through the [fetch
plugin](plugins/fetch.md)) lets the app draw and take events meanwhile,
and a generator turns the run loop between its yields.
The context and the calling window
A method whose first parameter is a CallContext gets one that is done
when the calling page navigates away or its window closes (done(), and
afterFunc($fn) runs a function then). window() returns the window that
called, for example to attach a dialog to it, and page() the page, which
tells apart the pages of the web views of a window of native UI ([Web
view](ui/web-view.md)):
/** Asks where to save the notes and writes them there: the chosen path, or '' when the user canceled. */
public function export(CallContext $ctx): string
{
$path = Dialog::save(new SaveDialogOptions(
parent: $ctx->window(), // a sheet of that window on macOS
defaultPath: 'notes.json',
));
if ($path === '') {
return '';
}
file_put_contents($path, json_encode($this->items));
return $path;
}
The context is not a parameter on the TypeScript side:
Notes.export(): Promise<string>.
The generated client
myphp generate writes the client to the path of bindings in
myphp.config.ts or myphp.json, src/myphp.ts in new projects. myphp dev
regenerates it whenever the PHP code changes and myphp build before it
builds the frontend, so you rarely run it yourself. For the service above
it writes:
// Code generated by `myphp generate`. DO NOT EDIT.
import { call, event } from "myphp-runtime";
/** A note. */
export interface Note {
id: number;
title: string;
body: string | null;
}
/** Stores the user's notes. */
export const Notes = {
/** Creates a note and returns it. */
add(title: string): Promise<Note> {
return call("Notes.add", title);
},
/** All notes. */
list(): Promise<Note[]> {
return call("Notes.list");
},
} as const;
Parameters keep their PHP names, and doc comments become TSDoc. The client
imports myphp-runtime, which the frontend depends on.
A parameter with a default is optional in the client: string $date = ''
is date?: string. JSON has no undefined, so an argument left out
arrives as null when one after it is given, or as the client always
passes every parameter: PHP then takes the parameter's default when its
type takes no null ('' for $date), as mygo's Go side takes the zero
value of a parameter left out or null. A nullable parameter
(?string $date = 'today') takes the null: give it null as its
default, or test for null in the method, so that leaving it out and
passing null mean the same.
To generate the client, myphp generate runs the app with
MYPHP_GENERATE set: App::run() then writes the client and returns
without opening a window. So the script runs up to App::run(); keep work
that must not happen then, such as migrating a database, after the app is
ready, in App::whenReady().
Types
Values cross between PHP and JavaScript as JSON, and the client declares
the matching types: bool is boolean, string string, int and
float number, a nullable type T | null, a class with public
properties an interface of the same name, and mixed unknown. Arrays
take their shape from PHPDoc: list<Note> and Note[] are Note[],
array<string, int> is Record<string, number>; a bare array is
unknown. JavaScript numbers are exact up to 2⁵³: encode larger IDs as
strings.
Errors
A method that throws rejects the promise with a CallError, whose message
is the exception's:
import { isCallError } from "myphp-runtime";
try {
await Notes.add("");
} catch (err) {
if (isCallError(err)) showError(err.message); // "a note needs a title"
else throw err;
}
err.method names the method that failed, "Notes.add".
Events
Events carry values from PHP to pages. Declare them with the type of their
payload, where the script runs before App::run(), so that `myphp
generate` finds them:
/** How far an export got. */
final class ExportProgress
{
public function __construct(public int $done, public int $total) {}
}
/** Sent while an export runs. */
$progress = new Event('progress', ExportProgress::class);
emit() sends an event to the page of one window, emitPage() to one
page, and broadcast() to every window:
$progress->emit($win, new ExportProgress(3, 10));
$progress->broadcast(new ExportProgress(10, 10));
Events emitted before the page's DOM is ready are delivered once it is.
The client lists events under events, named in camel case ("progress" →
events.progress, "notes:changed" → events.notesChanged):
import { events } from "./myphp";
const off = events.progress.on(({ done, total }) => {
bar.value = done / total;
});
// later
off();
events.progress.once((p) => console.log("first progress", p));
Event names must be unique; names starting with myphp: are reserved.
Channels
A method that produces values over time, such as the lines a command
prints, the tokens of a model's answer or the progress of a download,
streams them to its caller through a Channel parameter, like a response
that arrives in parts. A generator sends each value it yields, the run loop
turning between them:
/** Sends the lines of a file, a frame apart. */
public function tail(string $file, Channel $lines): \Generator
{
foreach (new SplFileObject($file) as $line) {
yield rtrim($line, "\n");
}
}
The client types the parameter as a Channel of myphp-runtime, which the
page creates and passes in its place, then iterates:
import { Channel } from "myphp-runtime";
import { Shell } from "./myphp";
const lines = new Channel<string>();
const done = Shell.tail("/var/log/system.log", lines);
for await (const line of lines) output.append(line + "\n");
await done; // rejects if tail threw
Instead of iterating, pass a function, `new Channel<string>((line) =>
output.append(line)), or set lines.onmessage`.
- Values arrive in order, all of them before the call's promise settles.
A method that is no generator sends with $lines->send($value).
- The channel closes when the method returns, or calls
close(): the loop
ends once it took the values sent before, and onclose is called.
- The page stops the stream with
lines.close(), or by breaking out of the
loop: send() then throws ChannelClosed, and a generator gets it
thrown at its yield. Navigating away and closing the window do the same.
- A generator waits while the page has more than a MiB of values yet to
take, so a method faster than the page does not pile them up in memory;
one that waits for something yields Channel::wait($seconds), sending
nothing and going on that much later.
A channel serves one call. Channels are parameters only.
Events or channels? An event goes to every listener of a page, for as long
as it lives; a channel carries the answer of one call and ends with it.
Without the generated client
myphp-runtime calls methods and subscribes to events by name, and pages
without a build step reach the same functions on window.myphp:
import { call, on } from "myphp-runtime";
const note = await call<Note>("Notes.add", "Groceries");
on<ExportProgress>("progress", (p) => console.log(p));
<script>
myphp.call("Notes.add", "Groceries").then((note) => console.log(note));
const lines = new myphp.Channel((line) => console.log(line));
myphp.call("Shell.tail", "/tmp/log.txt", lines);
</script>
Who may call
Only the app's own pages may call PHP methods: pages of its frontend and
of custom protocols (see the frontend), file: and
about: pages, and in development pages served from localhost, such as
the dev server. A window that shows other sites keeps them from calling PHP
unless PageOptions::$trustedOrigins lists their origin:
new Window(new WindowOptions(
url: 'https://app.example.com',
page: new PageOptions(trustedOrigins: ['https://app.example.com']),
));
'*' trusts every origin, including any site the page navigates to: avoid
it in windows that show content you do not control. Frames embedded in a
page never call PHP, whatever their origin; only the top-level page of a
window does. What else a trusted page may do, and how to show content that
is not the app's: see trust.
From PHP to the page
Events are the way to tell pages about changes. To run code in a page,
typically to automate or test it, use Page::eval(), which returns the
value of an expression, awaiting promises, decoded from JSON:
$title = $win->page()->eval('document.title');
$count = $win->page()->eval("const items = document.querySelectorAll('li'); return items.length");
Statements run as the body of an async function, so return produces the
value. A script that throws throws an EvalError.