This is the PHP code behind Integrate SurveyJS with your backend, as a Laravel application you can run step by step. Each step of that page has a page here that runs the real SurveyJS component against these routes, with a short "Try this" list, a live request log, a "What the server stored" panel and the code that just ran. SurveyJS has no backend of its own, so treat these as starting points for your own app.
- Live catalog: surveyjs-php.demos.surveyjs.io
- The page: surveyjs.io/backend-integration/examples
- SurveyJS demos: app.demos.surveyjs.io
- The same examples for other platforms: Node.js, ASP.NET Core, Python, Next.js
You need PHP 8.3 or later (with pdo_sqlite and fileinfo), Composer, and Node.js 22 or later.
git clone https://github.com/surveyjs/surveyjs-php.git
cd surveyjs-php
composer setup # install, .env, app key, SQLite database with demo data, npm install, npm run build
composer start # http://localhost:8000, serving the built assetsWhile you work on the code, run composer dev instead of composer start: it starts the server, Vite with hot reload and both relays.
- I.8 and III.5 (fill and edit together) need the WebSocket relays, two long-running processes:
composer relay(port 8081) andcomposer edit-relay(port 8082).composer devstarts them for you. - Section IV (validate, lint, PDF, extract) calls the SurveyJS service, which runs in Docker:
docker compose up -d surveyjspublishes it onhttp://localhost:3010. Then setVALIDATE_RESPONSES=trueandLINT_DEFINITIONS=truein.envto switch IV.1 and IV.2 on. - Everything in containers:
docker compose up --buildruns the app on port 8000, both relays, the scheduler and the service (see Docker).
composer start runs PHP's built-in server with upload limits that fit 5 MB files (php artisan serve starts a child process that doesn't inherit -d flags). See server.php.
The image runs with APP_ENV=production, APP_DEBUG=false and logs to docker compose logs (LOG_CHANNEL=stderr). These are environment variables, so they win over the .env the container copies from .env.example. PHP's built-in server runs 4 workers (PHP_CLI_SERVER_WORKERS, read from the process environment, not from .env), so a long IV.4 extraction doesn't hold up other visitors. That is enough for the demo; for your own production, serve Laravel with FrankenPHP or nginx + php-fpm. The scheduler service runs php artisan schedule:work for demo:prune.
The app trusts X-Forwarded-* headers, so behind a TLS proxy (Traefik, nginx, Cloudflare) its URLs, assets and cookies use https. To serve the relays on the same host, route their paths to them and set RELAY_URL and EDIT_RELAY_URL to that origin. For Traefik, next to your entry point and TLS labels:
services:
app:
labels:
- traefik.http.routers.surveyjs.rule=Host(`example.com`)
- traefik.http.services.surveyjs.loadbalancer.server.port=8000
relay:
labels:
- traefik.http.routers.surveyjs-relay.rule=Host(`example.com`) && PathPrefix(`/ws/rooms/`)
- traefik.http.services.surveyjs-relay.loadbalancer.server.port=8081
edit-relay:
labels:
- traefik.http.routers.surveyjs-edit-relay.rule=Host(`example.com`) && PathPrefix(`/ws/forms/`)
- traefik.http.services.surveyjs-edit-relay.loadbalancer.server.port=8082RELAY_URL=wss://example.com EDIT_RELAY_URL=wss://example.com docker compose up -d --buildCopy .env.example to .env (composer setup does it). Besides Laravel's own settings:
| Variable | Default | What it does |
|---|---|---|
SURVEYJS_LICENSE_KEY |
empty | Calls setLicenseKey() on every page. Without it, the commercial components (Dashboard, Creator) show a licence banner. |
SURVEYJS_SERVICE_URL |
http://localhost:3010 |
The SurveyJS service for Section IV. Docker Compose sets http://surveyjs:3000. |
VALIDATE_RESPONSES |
false |
IV.1: routes/examples/validate-response.php replaces save-response.php. |
LINT_DEFINITIONS |
false |
IV.2: routes/examples/lint-definition.php replaces the PUT in creator-load-save.php. |
SURVEYJS_AI_PROVIDER, SURVEYJS_AI_MODEL, SURVEYJS_OLLAMA_BASE_URL, OPENAI_API_KEY, ANTHROPIC_API_KEY |
empty | IV.4: the AI provider the service extracts with (openai, anthropic or ollama). Docker Compose passes them to the service. |
AI_BASE_URL, AI_API_KEY, AI_MODEL |
OpenAI, empty, gpt-4o-mini |
III.3: any OpenAI-compatible /chat/completions endpoint for machine translation. Without a key, /api/translate answers 501. |
RELAY_PORT, EDIT_RELAY_PORT |
8081, 8082 |
Where the relays listen. |
RELAY_URL, EDIT_RELAY_URL |
empty | The relay origin the pages use when a proxy serves the relays, for example wss://example.com: the pages add /ws/rooms/<id> and /ws/forms/<id>. Empty means this host on the ports above. |
DEMO_MODE |
false |
The hosted demo's visitor sandboxes (below). |
Code reads these through config/surveyjs.php, never with env(), so php artisan config:cache works.
Each step is two or three files you can read on their own:
routes/examples/<slug>.php: the step's routes, the server code the page shows.routes/examples.phploads them in page order. They run in the statelessapigroup, sofetch()needs no CSRF token.shared/client/<slug>.js: the step's client code, a plain ES module. Vite bundles it as an entry point (vite.config.js).resources/views/examples/<slug>.blade.php: the step page: the description, the "Try this" list and the form.- The relays are
app/Relay/FillRelay.php(I.8) andapp/Relay/EditRelay.php(III.5), started byphp artisan relay:fillandrelay:edit.
The code the Server Integration page shows is cut from these files between // #region sjs:<step>.server (or .client) and // #endregion. surveyjs-integration.json lists every step's files and regions; composer check verifies them, and composer contract runs the shared contract test (shared/contract-tests/run.mjs) against a fresh database, the app and both relays.
A response, a definition, saved progress and variable presets are each one JSON document, stored as text and returned as stored. Laravel changes request bodies in two ways by default, and the examples turn both off:
- The global
TrimStringsandConvertEmptyStringsToNullmiddleware skip/api/*(bootstrap/app.php), so" a "and""survive. - The routes decode the raw body with
json_decode($request->getContent()), which keeps{}as an object.$request->input()would turn it into a PHP array and save[].
The step tables use the query builder (DB::table()), so "a response is one JSON document" stays visible. An Eloquent model with protected $casts = ['data' => 'array'] works as well, with the same {} caveat (cast to 'object' to keep it).
Locally, everyone shares one SQLite database, database/database.sqlite (php artisan migrate:fresh --seed resets it).
On the hosted demo (DEMO_MODE=true), each visitor gets a private copy of the seeded database and uploads folder, chosen by a demo_sid cookie, and kept until it has been unused for 24 hours (php artisan demo:prune, scheduled hourly). The "Open a second window" link in I.8 and III.5 carries that id (?join=), so whoever opens it works in the same copy and the same relay rooms. This is demo-only: none of it is in the step code.
The routes only use the request, the database and the response, so they port in a few lines. I.1 in a Symfony controller:
#[Route('/api/responses', methods: ['POST'])]
public function saveResponse(Request $request, Connection $db): JsonResponse
{
$body = json_decode($request->getContent(), flags: JSON_THROW_ON_ERROR); // keeps {} as {}
$db->insert('responses', [
'form_id' => $body->formId,
'data' => json_encode($body->data, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION),
'created_at' => gmdate('Y-m-d\TH:i:s\Z'),
]);
return new JsonResponse(['id' => (int) $db->lastInsertId()], 201);
}And in Slim:
$app->post('/api/responses', function (Request $request, Response $response) use ($pdo) {
$body = json_decode((string) $request->getBody(), flags: JSON_THROW_ON_ERROR); // not getParsedBody(): keeps {} as {}
$pdo->prepare('INSERT INTO responses (form_id, data, created_at) VALUES (?, ?, ?)')
->execute([$body->formId, json_encode($body->data, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), gmdate('Y-m-d\TH:i:s\Z')]);
$response->getBody()->write(json_encode(['id' => (int) $pdo->lastInsertId()]));
return $response->withStatus(201)->withHeader('Content-Type', 'application/json');
});Tailwind CSS and Vite, as in any new Laravel app; the step pages are Blade views styled with Tailwind utility classes. SurveyJS comes from npm (survey-core, survey-js-ui, survey-creator-js, survey-analytics…, pinned to the version in shared/surveyjs-version.json), and the forms and Creator keep SurveyJS's own themes. The step modules in shared/client/ are plain ES modules with no Laravel or Tailwind in them, so they also work in Livewire, Inertia or a page that isn't Laravel at all; the other platform repositories load the same files through an import map.
These examples must not be used as a real service as they are. They don't cover authentication, authorization, user management, access levels and other security aspects of a real survey service; your framework's documentation covers those. The places where real checks go are marked:
Gate::define('edit-forms')andGate::define('read-file')inapp/Providers/AppServiceProvider.php: who may save definitions and presets, and who may read an uploaded file.app/Http/Middleware/DemoUser.php: the demo-user switch stands in for your session guard or Sanctum. The relays check the same cookie inFillRelay::user()andEditRelay::editor().
When you copy a step into your app, leave out the demo-only files: app/Http/Middleware/DemoUser.php, DemoSandbox.php and DemoCacheHeader.php (and their lines in bootstrap/app.php), app/Support/Demo.php, app/Support/Sandbox.php, app/Relay/RoomKey.php's sandbox helpers, app/Console/Commands/DemoPrune.php, routes/web.php, resources/views/, and shared/client/host.js (its mountSurvey() is one line: renderSurvey(survey, element) from survey-js-ui).