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 Channel parameters, 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.