Server Functions, Exposed Securely
Call PHP functions from your frontend as if they were local JavaScript functions.
Protected by the
#[Exposed]
attribute.
Attribute Security
Functions are private by default. You must explicitly opt-in using PHP 8 Attributes to expose them to the client.
Built-in Auth Checks
Stop writing if (!$user) return;.
Just add requiresAuth: true and the framework handles the guard.
Role Based Access
Granular control. Restrict execution to specific roles like
['admin', 'editor']
directly in the function signature.
Security & Authentication
Control who can access your backend logic using PHP Attributes.
use PP\Attributes\Exposed; // Only logged in users can call this #[Exposed(requiresAuth: true)] function getUserProfile() { return Auth::user(); }
use PP\Attributes\Exposed; // Implies requiresAuth: true #[Exposed(allowedRoles: ['admin', 'editor'])] function deletePost($args) { // Safe to perform admin action Post::delete($args->id); }
requiresAuth fails.
Data Flow Example
<?php use PP\Attributes\Exposed; #[Exposed] function updateUser($data) { // $data is an object automatically $email = $data->email; return [ 'success' => true, 'msg' => "Updated $email" ]; } ?>
<script> async function save() { const res = await pp.rpc( 'updateUser', { email: 'john@doe.com' } ); if (res.success) { console.log(res.msg); } } </script>
Rate limiting is always on
Every #[Exposed] call is rate-limited even when you never write
limits. The runtime falls back to
RATE_LIMIT_AUTH for functions that declare
requiresAuth and to
RATE_LIMIT_RPC otherwise — both default to
60 per minute in .env.
Setting limits overrides that default; it does not switch rate limiting on.
Calling methods, not just functions
pp.rpc(...) also dispatches to class members.
Use 'MyClass->method' for an exposed instance method and
'MyClass::staticMethod' for an exposed static one.
The #[Exposed] attribute goes on the method itself.
<?php
use PP\Attributes\Exposed;
class Reports
{
#[Exposed]
public function build($data) { /* pp.rpc('Reports->build', ...) */ }
#[Exposed(requiresAuth: true)]
public static function export($data) { /* pp.rpc('Reports::export', ...) */ }
}
The error contract
Framework-level failures never resolve as data. They arrive as an HTTP error status with an
{"error": "..."} body, and
pp.rpc(...) rejects
with an Error carrying that message.
Wrap calls in try/catch whenever the UI has to react to failure.
| Status | Meaning |
|---|---|
| 400 | Missing or invalid function name, or the function threw InvalidArgumentException — its message is forwarded verbatim. |
| 401 | requiresAuth is set and there is no authenticated session. Always surfaces as “Authentication required”. |
| 403 | Invalid origin, missing or invalid CSRF token, or a role mismatch. Always surfaces as “Permission denied”. |
| 404 | No exposed function with that name. A function that exists but is not #[Exposed] is indistinguishable from one that does not exist — on purpose. |
| 415 | The request body was neither JSON nor multipart. |
| 429 | Rate limit exceeded. |
| 500 | The exposed function threw. The message is generic unless SHOW_ERRORS is enabled. |
<script>
const [message, setMessage] = pp.state('');
async function save() {
try {
const result = await pp.rpc('saveProfile', { name });
// result is the exposed function's return value
} catch (error) {
// "Authentication required", "Permission denied",
// "Rate limit exceeded", ...
setMessage(error.message);
}
}
</script>
<?php
use PP\Attributes\Exposed;
use PP\Rule;
use PP\Validator;
#[Exposed]
function saveProfile($data)
{
$name = Validator::string($data->name ?? '');
$result = Validator::withRules($name, Rule::required()->min(3)->max(80));
// Routine validation feedback is DATA, not an exception.
if ($result !== true) {
return ['success' => false, 'errors' => ['name' => $result]];
}
return ['success' => true, 'errors' => [], 'data' => ['name' => $name]];
}
Do not model routine validation feedback as thrown errors. Return a structured object
(success, errors, normalized values) and render it.
Reserve the rejection path for genuine failures.
Two exceptions to the rejection rule: supplying onStreamError routes
all errors on that call to the handler and resolves undefined,
and an aborted call resolves with { cancelled: true }.
Cancelling stale calls
For fast-changing inputs such as live search, pass abortPrevious: true.
The abort slot is one per page, shared by the whole runtime — any
abortPrevious call cancels whichever
abortPrevious call is currently in flight anywhere in the app.
<script>
async function searchPosts(value) {
const response = await pp.rpc(
'searchPosts',
{ query: value },
{ abortPrevious: true }
);
// An aborted call RESOLVES with { cancelled: true } - it does not reject.
if (response?.cancelled) return;
setResults(response.items ?? []);
}
</script>
Transparent redirects
If an exposed function calls PP\Request::redirect($url),
the server answers 200 plus the
X-PP-Redirect header family, and
pp.rpc(...) performs the client-side navigation for you,
resolving with { redirected: true, to }.
Redirects are same-origin only; cross-origin targets are discarded with a console warning.
Real-time Streaming
Stream data from PHP to the client in real-time using Generators and Server-Sent Events (SSE).
<?php use PP\Attributes\Exposed; #[Exposed] function streamAIResponse($args) { yield "Connecting..."; // Simulate long running task sleep(1); yield ['status' => 'thinking', 'progress' => 50]; sleep(1); yield "Done!"; } ?>
<script> await pp.rpc( 'streamAIResponse', { prompt: 'Hello' }, { onStream: (chunk) => { // Auto-parsed as JSON or String console.log(chunk); }, onStreamComplete: () => { console.log('Stream finished'); } } ); </script>
Auto-JSON Parsing
If the chunk is a valid JSON string (array or object), PulsePoint automatically parses it before firing onStream.
Native PHP Generators
No complex event loop libraries required. Just use the native yield keyword in your PHP function.
Yield scalars and arrays — never objects. Arrays and booleans are JSON-encoded; every other value is string-cast, so yielding an object causes a fatal error.
The encoder writes one data: line per event and the client parser forwards only those lines,
so prefer JSON values or single-line strings over multi-line text blobs. If you need named events, event IDs or retry hints, drop down to
PP\Streaming\SSE and PP\Streaming\ServerSentEvent
with your own client.
A streamed response with no onStream handler logs a console warning and the promise simply resolves
once the stream ends — there is usually no final JSON payload to await.
File Uploads with Real Progress
When your payload includes a File,
PulsePoint automatically switches the request to multipart/form-data.
To track real upload progress, pass options.onUploadProgress.
Under the hood, PulsePoint uses XMLHttpRequest only for that case
(because fetch() cannot report upload progress).
<?php use PP\Attributes\Exposed; // Receives multipart/form-data automatically when File is detected in the payload #[Exposed(requiresAuth: true)] function processUpload($data) { // $data->title, $data->description, $data->folderId ... // Your UploadFile / move_uploaded_file logic here return [ 'success' => true, 'message' => 'Files uploaded successfully' ]; } ?>
<script> const [progress, setProgress] = pp.state(0); async function uploadFile(file) { const res = await pp.rpc( 'processUpload', { file, title: 'Document Title', description: 'Short description', folderId: 'root' }, { onUploadProgress: ({ percent }) => { // percent can be null if total is unknown if (percent != null) setProgress(Math.floor(percent)); }, onUploadComplete: () => { setProgress(100); } } ); if (res.success) { console.log(res.message); } } </script>
Zero-Config Multipart
If PulsePoint detects File / FileList in your payload, it auto-builds FormData.
No manual encoding required.
Progress Without UI Hacks
No fake setProgress(15) or setProgress(100). The UI reflects real upload bytes via onUploadProgress.
Client API Reference
pp.rpc<T = any>( functionName: string, data: Record<string, any> = {}, options: boolean | RpcOptions = false ): Promise<T | void> // RpcOptions (Streaming + Upload Progress) type RpcOptions = { abortPrevious?: boolean; // Streaming (SSE) onStream?: (chunk: any) => void; onStreamError?: (error: any) => void; onStreamComplete?: () => void; // Upload Progress (only when payload contains File/FileList) onUploadProgress?: (info: { loaded: number; total: number | null; percent: number | null; }) => void; onUploadComplete?: () => void; // Request targeting url?: string; // post to another route instead of the current pathname csrfUrl?: string; // fetched once to prime the CSRF cookie when missing credentials?: RequestCredentials; };
-
functionNameThe exact name of the PHP function. Method dispatch is also supported:MyClass->methodcalls an exposed instance method andMyClass::staticMethodcalls an exposed static method. -
dataObject containing arguments. AFileor non-emptyFileListswitches the request to multipart/form-data — but only as a top-level value. Detection does not recurse, so aFilenested inside an object or array is JSON-stringified and lost. -
options.onStreamCallback fired for every SSE chunk received. JSON-like chunks are parsed into JavaScript values; plain text stays a string. Only lines starting withdata:(colon plus space) are forwarded —event:,id:andretry:are ignored. -
options.onUploadProgressFired during file upload. Receivesloaded,total(or null), andpercent(0–100 or null). When this handler is present, PulsePoint uses XHR for that request (enabling upload progress). -
options.abortPreviousCancels the previousabortPreviouscall before starting this one. The abort slot is shared by the whole page, not scoped per function or component. An aborted call resolves with{ cancelled: true }instead of rejecting.
Zero-Config File Uploads
PulsePoint detects File objects in your payload and automatically switches the content-type.
No need for manual FormData construction.
Auto-Response Parsing
PHP arrays are automatically converted to JSON objects on the frontend. Strings and booleans are preserved.