Skip to content
cccskills
FreePublished on GitHub

openui-forge-php

OpenUI generative UI with a PHP (Laravel 13.x) backend. Forwards OpenAI's SSE stream verbatim via response()->stream().

The English summary is being prepared. Showing the original description.

Works withOpenAI
See how to install

Files included (1)

  • SKILL.md10.2 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

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/guzzle ships with Laravel and backs the Http facade (no extra dependency to call OpenAI)
  • OPENAI_API_KEY environment variable set

Quick Start

  1. Create the React frontend and install OpenUI deps:
npm install @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang lucide-react zod
  1. Generate the system prompt:
npx @openuidev/cli generate ./src/lib/library.ts --out backend/storage/app/system-prompt.txt
  1. Create the Laravel backend (see Full Code below). On a fresh app, enable API routes once with php artisan install:api.
  2. Run: php artisan serve on :8000, frontend on :3000

Full Code

Backend: composer.json (require block)

{
    "require": {
        "php": "^8.3",
        "laravel/framework": "^13.0"
    }
}

Laravel bundles guzzlehttp/guzzle, so the Http facade 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 HandleCors middleware reads this config and answers the OPTIONS preflight automatically, so the controller never hand-writes Access-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 calling flush() after each one, so the client sees tokens as they arrive. Pair it with openAIAdapter() on the frontend. openAIReadableStreamAdapter() is for NDJSON (no data: 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 breaks openAIAdapter(). Plain response()->stream() keeps OpenAI's data: {chunk} frames and the literal data: [DONE] intact.

An official-style OpenAI PHP client exists (openai-php/client, requires PHP 8.2+) with a createStreamed() helper as an alternative to calling the HTTP endpoint directly. This skill keeps the bundled Guzzle-backed Http facade as the dependency-free default. A pure ext-curl + CURLOPT_WRITEFUNCTION variant (return the chunk's byte count from the callback, echo+flush() inside it) is shown in templates/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.txt exists at storage/app/system-prompt.txt in the Laravel backend
  • OPENAI_API_KEY is set in .env (and OPENAI_BASE_URL / OPENAI_MODEL if overriding)
  • php artisan install:api has been run so POST /api/chat exists
  • config/cors.php allowed_origins lists the frontend origin (not *)
  • Response streams SSE directly from OpenAI API (passthrough via response()->stream())
  • X-Accel-Buffering: no header is set so nginx/FastCGI does not buffer the stream
  • Frontend apiUrl points to http://localhost:8000/api/chat
  • Frontend uses streamProtocol={openAIAdapter()} and openAIMessageFormat
  • componentLibrary={openuiChatLibrary} prop passed to FullScreen
  • CSS import in root layout (@openuidev/react-ui/components.css)

Error Patterns

ErrorCauseFix
CORS blockedOrigin mismatchSet allowed_origins in config/cors.php to the frontend origin
404 on /api/chatAPI routes not installedRun php artisan install:api and confirm routes/api.php exists
system-prompt.txt not foundFile missing from storage/appRun the CLI generate command into storage/app/system-prompt.txt
500 OPENAI_API_KEY not setEnv var missing, or read via env() after config:cacheSet 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 wrongCheck OPENAI_API_KEY, OPENAI_MODEL, OPENAI_BASE_URL
Response arrives all at once (no streaming)Output buffered by serverKeep X-Accel-Buffering: no; ensure flush() runs and no full-page output buffer wraps it
Empty responseWrong adapterUse openAIAdapter() (SSE), not openAIReadableStreamAdapter() (NDJSON)

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

Build generative UI with OpenUI — any LLM provider, any backend language. Scaffold, integrate, validate.

The English summary is being prepared. Showing the original description.

OthmanAdi/openui-forge22Updated Aug 3, 2026

OpenUI generative UI with Anthropic Claude SDK backend. Stream conversion to OpenAI NDJSON format.

The English summary is being prepared. Showing the original description.

OthmanAdi/openui-forge22Updated Aug 3, 2026

OpenUI generative UI with C# ASP.NET Core Minimal API backend. Direct OpenAI API SSE streaming via HttpClient on .NET 10.

The English summary is being prepared. Showing the original description.

OthmanAdi/openui-forge22Updated Aug 3, 2026

OpenUI generative UI with Elixir Phoenix backend. Chunked SSE streaming via Plug.Conn and Req.

The English summary is being prepared. Showing the original description.

OthmanAdi/openui-forge22Updated Aug 3, 2026

OpenUI generative UI with Go (net/http) backend. Direct OpenAI API streaming via HTTP.

The English summary is being prepared. Showing the original description.

OthmanAdi/openui-forge22Updated Aug 3, 2026

OpenUI generative UI with a Java Spring Boot (WebFlux) backend. Streams the OpenAI API directly via WebClient as SSE.

The English summary is being prepared. Showing the original description.

OthmanAdi/openui-forge22Updated Aug 3, 2026

See all skills by OthmanAdi

Report a problem with this skill