Openui forge php
Cross-IDE, multi-stack agent skill for OpenUI (the Open Standard for Generative UI). Adds OpenUI to existing projects across 12 backend stacks, any LLM provider, and 11 agent platforms. Scaffold, integrate, validate.
npx -y skills add OthmanAdi/openui-forge --skill openui-forge-phpAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 20 stars20 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
OpenUI generative UI with a PHP (Laravel 13.x) backend. Forwards OpenAI's SSE stream verbatim via response()->stream().
SKILL.md
10.2 KB, as published. Nobody here has run it
OpenUI Forge — PHP
Build generative UI apps with a React frontend + Laravel backend. Streams the OpenAI API's native SSE response straight through response()->stream().
Activation Triggers
- "openui php", "openui laravel", "openui php backend"
- "generative ui php", "laravel streaming ui backend"
Prerequisites
- Node.js >= 22 (24 LTS recommended) + React >= 18.3.1 (19+ recommended) (frontend)
- PHP >= 8.3 + Laravel 13.x (backend; Laravel 13 requires PHP 8.3 minimum and supports 8.3 through 8.5)
- Composer;
guzzlehttp/guzzleships with Laravel and backs theHttpfacade (no extra dependency to call OpenAI) OPENAI_API_KEYenvironment variable set
Quick Start
- Create the React frontend and install OpenUI deps:
npm install @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang lucide-react zod
- Generate the system prompt:
npx @openuidev/cli generate ./src/lib/library.ts --out backend/storage/app/system-prompt.txt
- Create the Laravel backend (see Full Code below). On a fresh app, enable API routes once with
php artisan install:api. - Run:
php artisan serveon:8000, frontend on:3000
Full Code
Backend: composer.json (require block)
{
"require": {
"php": "^8.3",
"laravel/framework": "^13.0"
}
}
Laravel bundles
guzzlehttp/guzzle, so theHttpfacade can call OpenAI with no extra package. The OpenAI SSE passthrough below needs nothing beyond the framework.
Backend: routes/api.php
<?php
use App\Http\Controllers\ChatController;
use Illuminate\Support\Facades\Route;
// `php artisan install:api` creates this file and prefixes it with /api,
// so this route is reachable at POST /api/chat.
Route::post('/chat', [ChatController::class, 'chat']);
Backend: app/Http/Controllers/ChatController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ChatController extends Controller
{
// Loaded once per worker process, then reused across requests.
private static ?string $systemPrompt = null;
private function systemPrompt(): string
{
if (self::$systemPrompt === null) {
$path = storage_path('app/system-prompt.txt');
if (! is_file($path)) {
abort(500, 'system-prompt.txt not found at ' . $path);
}
self::$systemPrompt = (string) file_get_contents($path);
}
return self::$systemPrompt;
}
public function chat(Request $request): StreamedResponse
{
// Read config(), never env(), in a controller: after `php artisan
// config:cache` (standard in production) env() returns null outside
// config files. See the config/services.php block below.
$apiKey = config('services.openai.key');
if (! $apiKey) {
abort(500, 'OPENAI_API_KEY not set');
}
$incoming = $request->validate([
'messages' => 'required|array|min:1',
'messages.*.role' => 'required|string',
'messages.*.content' => 'required|string',
])['messages'];
// Prepend the system prompt; never trust a client-sent system message.
$messages = array_merge(
[['role' => 'system', 'content' => $this->systemPrompt()]],
array_map(
fn (array $m) => ['role' => $m['role'], 'content' => $m['content']],
$incoming,
),
);
$baseUrl = rtrim(config('services.openai.base_url'), '/');
$model = config('services.openai.model');
// stream => true returns the Guzzle PSR-7 response with its body still
// on the wire, so we read it chunk-by-chunk instead of buffering the
// whole completion in memory.
$upstream = Http::withToken($apiKey)
->withOptions(['stream' => true])
->acceptJson()
->post("{$baseUrl}/chat/completions", [
'model' => $model,
'stream' => true,
'messages' => $messages,
]);
if ($upstream->failed()) {
abort($upstream->status(), 'OpenAI request failed: ' . $upstream->body());
}
$body = $upstream->toPsrResponse()->getBody();
return response()->stream(function () use ($body): void {
// Forward OpenAI's SSE bytes verbatim. OpenAI already emits
// `data: {chunk}\n\n` frames plus a final `data: [DONE]`, which is
// exactly what openAIAdapter() parses, so no re-framing is needed.
while (! $body->eof()) {
$chunk = $body->read(8192);
if ($chunk === '') {
usleep(1000); // avoid a busy-wait if the stream momentarily has no data
continue;
}
echo $chunk;
if (ob_get_level() > 0) {
@ob_flush();
}
flush();
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no',
]);
}
}
Backend: config/services.php (add an openai entry)
Read provider settings via config(), not env(), in the controller: after php artisan config:cache (standard in production) env() returns null outside config files. env() is only safe inside config files like this one.
<?php
return [
// ...existing services...
'openai' => [
'key' => env('OPENAI_API_KEY'),
'base_url' => env('OPENAI_BASE_URL', 'https://api.openai.com/v1'),
'model' => env('OPENAI_MODEL', 'gpt-5.5'),
],
];
Backend: config/cors.php (publish once with php artisan config:publish cors)
<?php
return [
'paths' => ['api/*'],
'allowed_methods' => ['POST', 'OPTIONS'],
// Pin the frontend origin, not a wildcard.
'allowed_origins' => [env('FRONTEND_ORIGIN', 'http://localhost:3000')],
'allowed_headers' => ['Content-Type', 'Authorization'],
'exposed_headers' => [],
'max_age' => 0,
// Leave false unless you send cookies; with credentials a wildcard is illegal.
'supports_credentials' => false,
];
Laravel's built-in
HandleCorsmiddleware reads this config and answers theOPTIONSpreflight automatically, so the controller never hand-writesAccess-Control-*headers.
Frontend: app/chat/page.tsx
"use client";
import { FullScreen } from "@openuidev/react-ui";
import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
import {
openAIAdapter,
openAIMessageFormat,
} from "@openuidev/react-headless";
export default function ChatPage() {
return (
<FullScreen
componentLibrary={openuiChatLibrary}
streamProtocol={openAIAdapter()}
messageFormat={openAIMessageFormat}
apiUrl="http://localhost:8000/api/chat"
/>
);
}
The Laravel backend forwards OpenAI's SSE stream verbatim through
response()->stream(), reading the Guzzle PSR-7 body in chunks and callingflush()after each one, so the client sees tokens as they arrive. Pair it withopenAIAdapter()on the frontend.openAIReadableStreamAdapter()is for NDJSON (nodata:prefix) and will silently produce no output here.Do not reach for Laravel's
response()->eventStream()here: it wraps each yield as a named SSE event and appends a</stream>sentinel, which rewrites the bytes and breaksopenAIAdapter(). Plainresponse()->stream()keeps OpenAI'sdata: {chunk}frames and the literaldata: [DONE]intact.An official-style OpenAI PHP client exists (
openai-php/client, requires PHP 8.2+) with acreateStreamed()helper as an alternative to calling the HTTP endpoint directly. This skill keeps the bundled Guzzle-backedHttpfacade as the dependency-free default. A pureext-curl+CURLOPT_WRITEFUNCTIONvariant (return the chunk's byte count from the callback,echo+flush()inside it) is shown intemplates/handler-php.php.template.
System Prompt Generation
npx @openuidev/cli generate ./src/lib/library.ts --out backend/storage/app/system-prompt.txt
Validation Checklist
-
system-prompt.txtexists atstorage/app/system-prompt.txtin the Laravel backend -
OPENAI_API_KEYis set in.env(andOPENAI_BASE_URL/OPENAI_MODELif overriding) -
php artisan install:apihas been run soPOST /api/chatexists -
config/cors.phpallowed_originslists the frontend origin (not*) - Response streams SSE directly from OpenAI API (passthrough via
response()->stream()) -
X-Accel-Buffering: noheader is set so nginx/FastCGI does not buffer the stream - Frontend
apiUrlpoints tohttp://localhost:8000/api/chat - Frontend uses
streamProtocol={openAIAdapter()}andopenAIMessageFormat -
componentLibrary={openuiChatLibrary}prop passed toFullScreen - CSS import in root layout (
@openuidev/react-ui/components.css)
Error Patterns
| Error | Cause | Fix |
|---|---|---|
| CORS blocked | Origin mismatch | Set allowed_origins in config/cors.php to the frontend origin |
404 on /api/chat | API routes not installed | Run php artisan install:api and confirm routes/api.php exists |
system-prompt.txt not found | File missing from storage/app | Run the CLI generate command into storage/app/system-prompt.txt |
500 OPENAI_API_KEY not set | Env var missing, or read via env() after config:cache | Set OPENAI_API_KEY in .env and read it through config('services.openai.key') (cache-safe) |
Upstream error surfaced via abort() | OpenAI key invalid or model wrong | Check OPENAI_API_KEY, OPENAI_MODEL, OPENAI_BASE_URL |
| Response arrives all at once (no streaming) | Output buffered by server | Keep X-Accel-Buffering: no; ensure flush() runs and no full-page output buffer wraps it |
| Empty response | Wrong adapter | Use openAIAdapter() (SSE), not openAIReadableStreamAdapter() (NDJSON) |