Integrate SurveyJS with PHP
SurveyJS libraries enable you to display and edit web forms, collect answers, analyze results, and generate PDF forms in the browser. Your backend supplies the data these libraries need and stores the changes they produce. The libraries exchange JSON with your backend, which allows you to use any database and storage services.
This guide explains how to integrate SurveyJS with your PHP backend. It walks through all backend-specific workflows, starting with the minimum integration: saving a completed response. Each workflow description provides client and server code samples and a link to a live example.
Run Forms
SurveyJS Form Library displays a form using a JSON schema and collects the respondent's answers. Start integration by adding an endpoint that saves those answers. You can then load data from the server, save progress in unfinished forms, and add the other features described below if your application needs them.
Save Responses
To collect responses, your backend needs an endpoint that accepts and stores answers. The form JSON schema can be a JavaScript constant on the page for now—this first step does not require an endpoint for loading it.
When the respondent completes the form, SurveyJS raises the onComplete event. Handle it to send survey.data and the form ID to your save endpoint. The backend should store the answers as a single JSON document and return the saved response's ID to confirm that the save succeeded.
Store the response exactly as it arrives—spaces, empty strings, and empty objects may be meaningful.
Client code:
import { Model } from "survey-core";
// The definition can be a constant in the page — no database needed
const definition = {
elements: [
{ type: "text", name: "email", title: "Your email", inputType: "email", isRequired: true },
{ type: "comment", name: "message", title: "How can we help?" }
]
};
const survey = new Model(definition);
// One endpoint: post the answers when the respondent completes the form
survey.onComplete.add(async (sender) => {
await fetch("/api/responses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ formId: "contact", data: sender.data })
});
});
Server code:
// POST /api/responses — store the answers exactly as they arrive
// bootstrap/app.php turns off TrimStrings and ConvertEmptyStringsToNull for /api/*: they would change " a " and ""
Route::post('/api/responses', function (Request $request) {
// The raw body, not $request->input(): PHP arrays would turn {} into [] in the stored JSON
$body = json_decode($request->getContent(), flags: JSON_THROW_ON_ERROR);
abort_unless(isset($body->formId, $body->data), 400, 'Expected { formId, data }');
$id = StoredJson::insertResponse($body->formId, $body->data); // one JSON document, stored as it arrived: 1.0 stays 1.0
return response()->json(['id' => $id], 201);
});
Load JSON Schemas, Variables, and Answers
As your application grows, the same form may need to dynamically change, behave differently for different users, or open with answers already filled in. Your backend can provide the JSON schema, variables, and previous answers needed to prepare the form.
JSON schema
Form JSON schema describes the questions, layout, and logic. You can include the schema in the page when the latter renders or fetch it from an endpoint before displaying the form. In either case, the server can dynamically adjust the schema for the current request.Variables
Variables provide context for the form. For example, the current user's name can appear in a title, and their subscription plan can control which questions are visible. Variables are excluded from the saved answers.Previous answers
If the user is returning to an existing record, also load its answers and assign them tosurvey.data. The form then opens with those values already filled in.
An endpoint can return all three pieces of data in one request.
Client code:
import { Model } from "survey-core";
async function createSurvey(recordId) {
if (window.FORM_DEFINITION) {
// Option A — your server rendered the definition into the page
return new Model(window.FORM_DEFINITION);
}
// Option B — one request returns the definition, variables and previous answers
const { definition, variables, data } =
await fetch(`/api/forms/claim-request?record=${recordId}`).then(r => r.json());
const survey = new Model(definition);
survey.setVariables(variables); // all at once; read as {user.name}, {plan}… and never saved with the response
survey.data = data; // previous answers: edit instead of starting blank
return survey;
}
Server code:
// Option A — render the definition into the page: no database, no extra request
Route::get('/claim', function () {
$definition = json_decode(file_get_contents(base_path('shared/definitions/load-definition-variables-data.json')));
$definition->title = 'Hi '.Str::before(Auth::user()->name ?? 'there', ' '); // change it per request if you like
return view('examples.load-definition-variables-data', ['definition' => $definition]); // Js::from() escapes it for an inline script
});
// Option B — definition, variables and previous answers in one response
Route::get('/api/forms/{id}', function (Request $request, string $id) {
$user = Auth::user();
$definition = DB::table('forms')->where('key', $id)->value('json') ?? abort(404, 'Unknown form');
$data = $request->filled('record') ? DB::table('responses')->where('id', $request->query('record'))->value('data') : null;
return StoredJson::response([
'definition' => json_decode($definition), // a stored document, as stored
'variables' => ['user' => ['name' => $user?->name, 'plan' => $user?->plan]],
'data' => $data === null ? new stdClass : json_decode($data), // previous answers, or {}
]);
});
Live Example: Load JSON Schemas, Variables, and Answers
Resume Progress
With long forms, respondents may need to stop and return later. You can save their answers together with the form's UI state, which includes the current page and focused input field. Restoring both lets them continue from the same place.
While the respondent works, listen for answer and page changes and send the updated progress to the backend. Answer changes can happen on every keystroke, so wait for a short pause before saving. Send any pending changes when users close or reload the browser tab as well.
The backend should store one progress document for each user and form. When the respondent returns, load that document and restore the answers and UI state before they continue.
Client code:
import debounce from "lodash/debounce";
// On return: restore both — the user continues on the same page, in the same input.
// Restore before listening, so restoring the page doesn't count as a change to save.
const saved = await fetch(`/api/progress/${formId}`).then(r => (r.ok ? r.json() : null));
if (saved) {
survey.data = saved.data;
survey.uiState = saved.uiState;
}
// Save answers + UI state as the user works (uiState: see the Form Library API reference)
const saveProgress = debounce(() => {
fetch(`/api/progress/${formId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: survey.data, uiState: survey.uiState }),
keepalive: true // lets the last request finish while the tab closes
});
}, 800);
survey.onValueChanged.add(saveProgress);
survey.onCurrentPageChanged.add(saveProgress);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") saveProgress.flush();
});
Server code:
// GET /api/progress/{formId} — return the stored progress (answers + UI state) (404 if none yet)
Route::get('/api/progress/{formId}', function (string $formId) {
$json = DB::table('progress')->where('key', $formId)->value('json') ?? abort(404, 'No progress saved yet');
return response($json)->header('Content-Type', 'application/json');
});
// PUT /api/progress/{formId} — store the progress (answers + UI state) as-is: it's one JSON document
// Real code keys it by user and form, e.g. Auth::id().':'.$formId; the samples key by form for brevity
Route::put('/api/progress/{formId}', function (Request $request, string $formId) {
// The raw body, not $request->input(): PHP arrays would turn {} into [] in the stored JSON
$progress = json_decode($request->getContent(), flags: JSON_THROW_ON_ERROR);
StoredJson::put('progress', $formId, $progress); // stored as it arrived: 1.0 stays 1.0
return response()->noContent();
});
Store Files Separately
A File Upload question can store file contents in the response as Base64 strings. This makes responses much larger. Alternatively, you can keep files in a separate storage. To do this, set the question's storeDataAsText property to false and handle the survey's onUploadFiles event.
The event handler should send selected files to an upload endpoint. The backend stores each file and returns its ID or URL. The form saves that reference in the answers, leaving the file contents out of the response document.
For private files, use IDs and a download endpoint that checks the user's access before returning a file. Handle the onDownloadFile event to retrieve the content when the form needs to display a preview.
Client code:
// Upload: your server stores the files and returns an id (or a URL) for each
survey.onUploadFiles.add(async (_, options) => {
const body = new FormData();
options.files.forEach(file => body.append("files[]", file)); // "files[]": PHP keeps only the last of several "files"
const ids = await fetch("/api/files", { method: "POST", body }).then(r => r.json());
// The response stores these ids, not the file contents
options.callback(options.files.map((file, i) => ({ file, content: ids[i] })));
});
// Preview: only when the stored value is not a public URL the browser can open
survey.onDownloadFile.add(async (_, options) => {
const res = await fetch(`/api/files/${encodeURIComponent(options.content)}`);
if (!res.ok) return options.callback("error");
const reader = new FileReader();
reader.onload = () => options.callback("success", reader.result); // base64 data the form shows
reader.readAsDataURL(await res.blob());
});
Server code:
// POST /api/files — store uploads, return their ids in the same order
Route::post('/api/files', function (Request $request) {
$ids = [];
foreach (Arr::wrap($request->file('files')) as $file) { // field "files[]": PHP keeps only the last of several "files"
abort_unless($file->isValid(), 400, $file->getErrorMessage());
$id = (string) Str::uuid();
$file->storeAs('', $id, 'uploads'); // the "uploads" disk: local, S3…; named by id, never by the client
DB::table('files')->insert(['id' => $id, 'name' => $file->getClientOriginalName(), 'type' => $file->getMimeType()]);
$ids[] = $id;
}
return response()->json($ids);
});
// GET /api/files/{id} — the content, for private files only; check access first
Route::get('/api/files/{id}', function (string $id) {
$file = DB::table('files')->find($id);
abort_if(! $file || Gate::denies('read-file', $file), 404); // 404, not 403: don't confirm the file exists
return Storage::disk('uploads')->response($id, $file->name, [ // nosniff and sandbox: an uploaded HTML or SVG never runs as this site
'Content-Type' => $file->type, 'X-Content-Type-Options' => 'nosniff', 'Content-Security-Policy' => 'sandbox',
]);
});
Live Example: Store Files Separately
Load Choices from Web
Choice lists often come from data your application already stores, such as products or offices. Use the choicesByUrl property to load those options into a Dropdown, Checkboxes, or Radio Button Group question. The configured endpoint should return the list, and SurveyJS displays it as the question's choices.
The URL can include answers or variables in curly braces. For example, /api/offices?region={region} uses the selected region to request matching offices. When that answer changes, SurveyJS requests the list again.
The browser calls the URL directly. If an external service needs a secret key or does not allow cross-origin requests, have your backend call it and return the choices to the browser. If your own endpoint requires authentication headers, add them through settings.web.onBeforeRequestChoices.
Client code:
// In the definition — no code (shared/definitions/choices-from-web.json):
// "choicesByUrl": { "url": "/api/offices?region={region}", "valueName": "id", "titleName": "name" }
// "choicesByUrl": { "url": "/api/countries", "valueName": "code", "titleName": "name" }
// {region} is replaced with the answer to "region" (or a variable), and the list reloads when it changes.
// Optional: add your auth header to every choices request
import { settings } from "survey-core";
settings.web.onBeforeRequestChoices = (_, options) => {
options.request?.setRequestHeader("Authorization", `Bearer ${token}`);
};
Server code:
// GET /api/offices?region= — {region} from the form arrives as a query parameter
Route::get('/api/offices', function (Request $request) {
return DB::table('offices')->where('region', $request->query('region', ''))->orderBy('name')->get(['id', 'name']);
});
// GET /api/countries — a proxy: the browser can't call a service without CORS, your server can
Route::get('/api/countries', function () {
try {
$countries = Cache::remember('countries', now()->addDay(), fn () => Http::timeout(5)
->get('https://restcountries.com/v3.1/all', ['fields' => 'name,cca2'])->throw()->collect()
->map(fn (array $country) => ['code' => $country['cca2'], 'name' => $country['name']['common']])
->sortBy('name')->values()->all());
} catch (Throwable $e) { // down, or a different answer: serve the bundled list for 10 minutes, so requests don't each wait for the timeout
Log::warning('restcountries.com failed; serving shared/seed/countries.json', ['error' => $e->getMessage()]);
$countries = json_decode(file_get_contents(base_path('shared/seed/countries.json')), true);
Cache::put('countries', $countries, now()->addMinutes(10));
}
return response()->json($countries)->header('Cache-Control', 'public, max-age=86400');
});
Live Example: Load Choices from Web
Async Calculations and Validation
Some form logic needs information that only your backend has. For example, a validation rule may need to check whether an email address is already registered, or a calculation may need the shipping rate for a postcode.
Register an expression function with isAsync: true and have it request that information from your backend. The function returns a promise. When the request finishes, SurveyJS uses the result to update the calculation, validation rule, or visibility condition that called the function.
Client code:
import { registerFunction } from "survey-core";
// Validation: does a customer with this email already exist?
registerFunction({
name: "emailExists",
isAsync: true,
func: async ([email]) => {
if (!email) return false;
const res = await fetch(`/api/customers/exists?email=${encodeURIComponent(email)}`);
return (await res.json()).exists;
}
});
// Calculation and validation: the shipping price for a postcode, null where you don't deliver
const prices = new Map(); // one request per postcode serves both the calculated value and the validator
registerFunction({
name: "shippingCost",
isAsync: true,
func: async ([postcode]) => {
if (!postcode) return 0;
if (!prices.has(postcode)) prices.set(postcode, fetch(`/api/shipping?postcode=${encodeURIComponent(postcode)}`).then(r => r.json()));
return (await prices.get(postcode)).price;
}
});
Server code:
// GET /api/customers/exists?email= — answers emailExists()
Route::get('/api/customers/exists', function (Request $request) {
$email = Str::lower(trim((string) $request->query('email')));
return ['exists' => DB::table('customers')->where('email', $email)->exists()];
});
// GET /api/shipping?postcode= — answers shippingCost(): the longest matching postcode prefix wins,
// { "price": null } when none matches (the form shows "We don't deliver to this postcode yet")
Route::get('/api/shipping', function (Request $request) {
$postcode = Str::upper(str_replace(' ', '', (string) $request->query('postcode')));
// only a whole German (10115) or UK (SW1A 1AA) postcode gets a price, not anything that starts with a prefix
if (! preg_match('/^(\d{5}|[A-Z]{1,2}\d[A-Z\d]?\d[A-Z]{2})$/', $postcode)) {
return ['price' => null];
}
$rate = DB::table('shipping_rates')->get()
->filter(fn ($rate) => str_starts_with($postcode, $rate->postcode_prefix))
->sortByDesc(fn ($rate) => strlen($rate->postcode_prefix))
->first();
return ['price' => $rate ? (float) $rate->price : null];
});
Live Example: Async Calculations and Validation
Use Relational Storage
Storing each response as JSON is enough to preserve its answers. If you also need to query those answers with SQL or join them with other application data, copy the relevant values into database columns.
First, give the answers stable keys. A question's valueName sets the key it writes into the response independently of its question name. Form authors can then rename or move questions without breaking the mapping to your columns. If you need more mapping information, add a custom question property and expose it in Survey Creator's Property Grid.
When saving, keep the original JSON and the version of the form JSON schema used to collect it. Then copy the mapped values into their columns. Save both in one transaction, so the records stay consistent. Keeping the original document and schema version also lets you rebuild the mapping if your tables change later.
Client code:
import { Model, Serializer } from "survey-core";
// Optional: a custom property for richer mapping, editable in Creator's property grid
Serializer.addProperty("question", { name: "dbColumn", category: "data" });
// valueName fixes the key your columns read → response: { "customer_email": "...", "amount": 1840.5 }
const survey = new Model(definitions[definitionVersion]);
// Post the original response with the version of the definition it was filled against
survey.onComplete.add(async (sender) => {
await fetch("/api/claims", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: sender.data, definitionVersion })
});
});
Server code:
// POST /api/claims — original JSON first, then the columns you query
Route::post('/api/claims', function (Request $request) {
// The raw body, not $request->input(): PHP arrays would turn {} into [] in the stored JSON
$body = json_decode($request->getContent(), flags: JSON_THROW_ON_ERROR);
abort_unless(isset($body->data, $body->definitionVersion), 400, 'Expected { data, definitionVersion }');
$id = DB::transaction(function () use ($body) {
$responseId = StoredJson::insertResponse('claim', $body->data, [ // the original, stored as it arrived: 1.0 stays 1.0
'definition_version' => $body->definitionVersion, // keep the original and its version
]);
DB::table('claims')->insert([ // keys come from valueName
'response_id' => $responseId,
'customer_email' => $body->data->customer_email ?? null,
'amount' => $body->data->amount ?? null,
]);
return $responseId;
});
return response()->json(['id' => $id], 201);
});
Live Example: Use Relational Storage
Fill Forms Together
Several people may need to fill out the same record, such as a dispatcher and a technician completing a job sheet. Participants see each other's answers, focus, and cursors.
To let participants fill out a form together, follow these steps:
- Add
CollaborationPluginfromsurvey-core/collaborationto enable this functionality. - Connect the clients to a WebSocket relay that groups participants by record. When someone joins, the relay sends the form JSON schema and current answers so that their form starts in the same state as everyone else's.
- Have the relay keep the latest value for each question and forward changes to the other clients. If two people change the same question, the last change wins.
- Save the completed response through the usual save endpoint.
Client code:
import { Model } from "survey-core";
import { CollaborationPlugin } from "survey-core/collaboration"; // v3.1.1+
// One plugin on the model: it emits messages and applies incoming ones, and draws the
// participant bar, focus rings and cursors itself. The page only moves JSON frames.
// The relay runs as its own process, so its URL comes from the page config (RELAY_URL).
const ws = new WebSocket(relayUrl("fill", `/ws/rooms/${recordId}?name=${encodeURIComponent(userName)}`));
let collab;
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "init" && !collab) {
const survey = new Model(msg.seed); // the server hands out the definition
collab = new CollaborationPlugin(survey, { getInviteLink: inviteLink });
collab.onEvent.add((_, { message }) => ws.send(JSON.stringify(message))); // out: value, presence
survey.onComplete.add(postResponse);
mountSurvey(survey);
}
collab?.apply(msg); // in: init, value, peer, peer-left, as they arrive
});
Server code:
// The relay, run as its own process: php artisan relay:fill (PHP-FPM can't hold connections).
// No SurveyJS here: the definition and the answers are opaque JSON.
private array $rooms = []; // room key => Room { seed, values, clients }
public function onConnect(TcpConnection $c, Request $request): void
{
$user = $this->user($request); // may this user open this record?
$recordId = $this->recordId($request);
$roomKey = $recordId === null ? null : RoomKey::fromHandshake($request, $recordId);
if (! $user || $roomKey === null) {
$c->close();
return;
}
$room = $this->rooms[$roomKey] ??= new Room(...$this->seed($recordId)); // the definition + the saved answers
$room->cancelDrop(); // back within 2 s: keep the room
$c->context->roomKey = $roomKey;
$c->context->id = bin2hex(random_bytes(8));
$c->context->name = self::name($request->get('name'), $user->name);
$c->context->slot = $room->freeSlot();
$c->context->state = null;
$room->clients[$c->id] = $c;
$this->send($c, ['type' => 'init', 'clientId' => $c->context->id, 'name' => $c->context->name,
'colorIndex' => Room::colorIndex($c->context->slot), 'seed' => $room->seed,
'values' => $room->snapshot(), 'peers' => $room->roster($c)]); // init first
}
public function onMessage(TcpConnection $from, string $raw): void
{
$msg = json_decode($raw); // objects stay objects: {} is relayed as {}
$room = $this->rooms[$from->context->roomKey ?? ''] ?? null;
if (! $room || ! $msg instanceof stdClass) {
return;
}
if (($msg->type ?? '') === 'value' && is_string($msg->key ?? null)) { // store, then fan out
$room->values[$msg->key] = $msg->value ?? null;
$room->broadcast($from, ['type' => 'value', 'from' => $from->context->id, 'key' => $msg->key, 'value' => $msg->value ?? null]);
} elseif (($msg->type ?? '') === 'presence' && isset($msg->state) && strlen($raw) <= 4096) { // stamp identity, relay
$retain = ($msg->retain ?? true) !== false;
if ($retain) {
$from->context->state = $msg->state;
}
$room->broadcast($from, ['type' => 'peer', 'retain' => $retain, 'peer' => Room::peer($from, $msg->state)], droppable: ! $retain);
} // unknown types: ignore
}
public function onClose(TcpConnection $c): void
{
$room = $this->rooms[$c->context->roomKey ?? ''] ?? null;
if (! $room) {
return;
}
unset($room->clients[$c->id]);
$room->broadcast($c, ['type' => 'peer-left', 'clientId' => $c->context->id]);
if (count($room->clients) === 0) { // drop the room 2 s after the last person leaves
$room->dropTimer = Timer::add(2, fn () => count($room->clients) === 0 && $this->drop($c->context->roomKey), [], false);
}
}
Live Example: Fill Forms Together
Analyze Results with Dashboard
Once you have collected responses, use SurveyJS Dashboard to display and analyze them. Dashboard runs in the browser and uses the form JSON schema to choose a chart for each question automatically.
Load the JSON schema and request the saved responses for that form. The backend should return the answers as a JSON array, which the client passes to Dashboard along with the schema.
As the number of responses grows, you can filter or paginate them on the server. This lets users load the data they need without sending every stored response to the browser.
Client code:
import { Model } from "survey-core";
import { VisualizationPanel } from "survey-analytics";
async function showDashboard(from) {
const { definition } = await fetch("/api/forms/feedback").then(r => r.json()); // .definition: the I.2 endpoint
const responses = await fetch(`/api/responses?formId=feedback&from=${from}`).then(r => r.json());
const survey = new Model(definition);
const dashboard = new VisualizationPanel(survey.getAllQuestions(), responses);
dashboard.render(document.getElementById("dashboard"));
return dashboard;
}
Server code:
// GET /api/responses?formId=&from=&limit=&offset= — the stored answers as an array
Route::get('/api/responses', function (Request $request) {
$documents = DB::table('responses')
->where('form_id', $request->query('formId'))
->where('created_at', '>=', $request->query('from', '1970-01-01')) // ISO-8601 UTC strings compare as dates
->orderBy('created_at')
->limit(min(max((int) $request->query('limit', 500), 1), 5000)) // filter or page on the server for large volumes
->offset(max((int) $request->query('offset', 0), 0))
->pluck('data');
return response('['.$documents->implode(',').']') // documents as stored, not re-encoded
->header('Content-Type', 'application/json');
});
Live Example: Analyze Results with Dashboard
Edit Forms in Survey Creator
Survey Creator lets authors build and edit forms in your application. It works with the same form JSON schemas that Form Library displays to respondents. Your backend should load those schemas when the editor opens and save them when an author makes changes.
Load and Save Form JSON Schemas
Start by loading the saved schema into Survey Creator. You can use the same endpoint that supplies the schema to Form Library.
When the author clicks Save, Survey Creator calls saveSurveyFunc. Use this function to send the updated JSON schema to your backend. The backend should check the author's permission to edit forms, store the JSON, and confirm that it was saved. The next time a respondent opens the form, Form Library loads the updated JSON schema.
To save after each change, set the autoSaveEnabled property to true. Survey Creator then calls the same save function automatically. If the backend rejects a save, the backend should report the failure to Survey Creator so that the author can see it.
Client code:
import { SurveyCreator } from "survey-creator-js";
const creator = new SurveyCreator({ showTranslationTab: true });
creator.JSON = (await fetch(`/api/forms/${formId}`).then(r => r.json())).definition; // the I.2 endpoint's .definition
creator.autoSaveEnabled = true; // save after each change; without it, Creator shows a Save button
creator.saveSurveyFunc = async (saveNo, callback) => {
const res = await fetch(`/api/forms/${formId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(creator.JSON)
});
callback(saveNo, res.ok); // tells Creator whether the save succeeded
if (!res.ok) creator.notify((await res.json()).error, "error"); // say why, e.g. 403: no editor rights
};
Server code:
// PUT /api/forms/{id} — store the definition as-is: it's one JSON document
Route::put('/api/forms/{id}', function (Request $request, string $id) {
Gate::authorize('edit-forms'); // editing is an admin action: 403 otherwise
// The raw body, not $request->input(): PHP arrays would turn {} into [] in the stored JSON
$definition = json_decode($request->getContent(), flags: JSON_THROW_ON_ERROR);
StoredJson::put('forms', $id, $definition); // stored as it arrived: 1.0 stays 1.0
return response()->noContent(); // the next page load renders the new version
});
Live Example: Load and Save Form JSON Schemas
Upload Files
Authors may need to add a logo, image, or file to a form. You can keep these files in your storage and save their URLs in the JSON schema to keep it small.
When an author selects a file, Survey Creator raises the onUploadFile event. Handle it by sending the file to your upload endpoint, then pass the returned URL back to Survey Creator. Survey Creator adds the URL to the JSON schema, and the next save preserves that reference.
You can reuse the storage endpoint for respondent uploads. If the file is private, its URL should point to a download endpoint that checks access before serving the file.
Client code:
// Images and logos added in Creator: store them, keep only the URL in the definition
creator.onUploadFile.add(async (_, options) => {
const body = new FormData();
options.files.forEach(file => body.append("files[]", file)); // the I.4 endpoint; "files[]" for PHP
const [id] = await fetch("/api/files", { method: "POST", body }).then(r => r.json());
options.callback("success", `/api/files/${id}`); // I.4 returns ids: the definition keeps a URL
});
Server code:
// POST /api/files — store uploads, return their ids in the same order
Route::post('/api/files', function (Request $request) {
$ids = [];
foreach (Arr::wrap($request->file('files')) as $file) { // field "files[]": PHP keeps only the last of several "files"
abort_unless($file->isValid(), 400, $file->getErrorMessage());
$id = (string) Str::uuid();
$file->storeAs('', $id, 'uploads'); // the "uploads" disk: local, S3…; named by id, never by the client
DB::table('files')->insert(['id' => $id, 'name' => $file->getClientOriginalName(), 'type' => $file->getMimeType()]);
$ids[] = $id;
}
return response()->json($ids);
});
// GET /api/files/{id} — the content, for private files only; check access first
Route::get('/api/files/{id}', function (string $id) {
$file = DB::table('files')->find($id);
abort_if(! $file || Gate::denies('read-file', $file), 404); // 404, not 403: don't confirm the file exists
return Storage::disk('uploads')->response($id, $file->name, [ // nosniff and sandbox: an uploaded HTML or SVG never runs as this site
'Content-Type' => $file->type, 'X-Content-Type-Options' => 'nosniff', 'Content-Security-Policy' => 'sandbox',
]);
});
Translate with AI
For multilingual forms, you can integrate AI in Survey Creator's Translations tab so that authors can translate titles, choices, and other form text semi-automatically.
When an author requests a translation, send the source strings and language information to your backend. The backend should call the AI provider you choose and return the translated strings in their original order. Keep the provider's API key on the server.
Authors can review and edit the translations in the UI before using them in the form.
Client code:
// Creator asks for machine translation; your server calls the AI provider
creator.onMachineTranslate.add(async (_, options) => {
const res = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ strings: options.strings, from: options.fromLocale, to: options.toLocale })
});
options.callback(res.ok ? await res.json() : []); // same order as options.strings; [] when it failed
});
Server code:
// POST /api/translate — strings in, translated strings out (same order); nothing goes to SurveyJS
Route::post('/api/translate', function (Request $request) {
abort_unless(config('surveyjs.ai.api_key'), 501, 'Set AI_API_KEY to enable translation');
['strings' => $strings, 'from' => $from, 'to' => $to] = $request->only(['strings', 'from', 'to']) + ['strings' => [], 'from' => '', 'to' => ''];
$translated = AiTranslation::translate($strings, $from, $to); // any OpenAI-compatible provider: your key, your choice
abort_unless(is_array($translated) && count($translated) === count($strings), 502, 'The AI provider returned an unexpected answer');
return response()->json($translated); // what options.callback() takes
});
Live Example: Translate with AI
Variable Presets
Form logic may depend on variables supplied by your application, such as a customer's subscription plan. Authors need sample values to test that logic before the form is used. Variable presets allow them to do that by providing named sets of those values in Survey Creator.
Load the saved presets from the backend when the editor opens. In the Preview tab, an author can select a preset to see how the form behaves with its values. Switching presets updates the preview using data already loaded in the browser.
When an author edits a preset, you need to save the updated presets to your backend as a JSON document. Check editing permission before saving, just as you do for form JSON schemas.
Client code:
import { SurveyCreator } from "survey-creator-js";
const creator = new SurveyCreator({
variablePresets: {
definition: { // describes the variables and how authors edit them
elements: [{
type: "dropdown", name: "customerTier", title: "Customer plan",
choices: ["basic", "premium"]
}]
},
presets: await fetch(`/api/variable-presets/${formId}`).then(r => (r.ok ? r.json() : []))
}
});
// Save the presets when an author edits them
creator.onVariablePresetsChanged.add((_, options) => {
if (options.reason === "edit") {
fetch(`/api/variable-presets/${formId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(options.variablePresets)
});
}
});
Server code:
// GET /api/variable-presets/{formId} — return the stored variable presets (404 if none yet)
Route::get('/api/variable-presets/{formId}', function (string $formId) {
$json = DB::table('variable_presets')->where('key', $formId)->value('json') ?? abort(404, 'No presets saved yet');
return response($json)->header('Content-Type', 'application/json');
});
// PUT /api/variable-presets/{formId} — store the variable presets as-is: it's one JSON document
Route::put('/api/variable-presets/{formId}', function (Request $request, string $formId) {
Gate::authorize('edit-forms'); // editing is an admin action: 403 otherwise
// The raw body, not $request->input(): PHP arrays would turn {} into [] in the stored JSON
$presets = json_decode($request->getContent(), flags: JSON_THROW_ON_ERROR);
StoredJson::put('variable_presets', $formId, $presets); // stored as it arrived: 1.0 stays 1.0
return response()->noContent();
});
Live Example: Variable Presets
Edit Forms Together
Several authors can work on the same form. Participants see each other's edits, selection, and cursors, and cannot edit survey elements that another participant is editing.
To let several authors work on the same form, follow these steps:
- Add
CollaborationPluginfromsurvey-creator-core/collaborationto enable this functionality. - Connect the clients to a WebSocket relay. Have the relay add each edit to a change log as a JSON record and forward it to the other participants.
- When someone joins, send them the starting JSON schema and the change log. Applying those changes brings the new participant's editor up to date.
- Save the JSON schema through
saveSurveyFuncand your usual save endpoint. After the collaboration session ends, the next session starts from the saved schema.
Client code:
// Every edit becomes a small JSON record; the plugin also captures and draws presence
const collab = new CollaborationPlugin(creator, { roomId: formId, getInviteLink: inviteLink });
creator.addPlugin("collaboration", collab);
// The relay runs as its own process, so its URL comes from the page config (EDIT_RELAY_URL)
const ws = new WebSocket(relayUrl("edit", `/ws/forms/${formId}?name=${encodeURIComponent(userName)}`));
let ready = false; // send nothing before init is applied
const sendPresence = () => ready && ws.send(JSON.stringify({ type: "presence", state: collab.getState() }));
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "init") {
creator.JSON = msg.seed; // the saved definition (III.1)…
if (msg.log.length) collab.apply(msg.log); // …plus every edit since, in order
ready = true;
sendPresence(); // announce yourself to the others
}
if (msg.type === "record") collab.apply(msg.payload);
if (msg.type === "presence-sync") collab.setPeers(msg.peers);
if (msg.type === "presence") collab.upsertPeer(msg.peer);
if (msg.type === "presence-leave") collab.removePeer(msg.clientId);
});
const sendRecord = (_, { record }) => ready && ws.send(JSON.stringify({ type: "append", payload: record }));
collab.onRecordAdded.add(sendRecord);
collab.onRecordChanged.add(sendRecord); // a coalesced record re-sent as it grows
collab.onStateChanged.add(sendPresence);
Server code:
// The same kind of relay as I.8, keeping an append-only log instead of a value map:
// Creator records must be replayed in order. Run as its own process: php artisan relay:edit
private array $rooms = []; // room key => EditRoom { seed, log, clients }
public function onConnect(TcpConnection $c, Request $request): void
{
$user = $this->editor($request); // may this user edit this form?
$formId = $this->formId($request);
$roomKey = $formId === null ? null : RoomKey::fromHandshake($request, $formId);
if (! $user || $roomKey === null) {
$c->close();
return;
}
$room = $this->rooms[$roomKey] ??= new EditRoom($this->savedDefinition($formId)); // seed = saved definition (III.1)
$room->cancelDrop(); // back within 2 s: keep the room
$c->context->roomKey = $roomKey;
$c->context->id = bin2hex(random_bytes(8));
$c->context->name = self::name($request->get('name'), $user->name);
$c->context->slot = $room->freeSlot();
$c->context->state = null;
$room->clients[$c->id] = $c;
$this->send($c, ['type' => 'init', 'clientId' => $c->context->id, 'colorIndex' => $c->context->slot,
'color' => EditRoom::PALETTE[$c->context->slot], 'seed' => $room->seed, 'log' => $room->log]); // init first
if ($peers = $room->roster($c)) {
$this->send($c, ['type' => 'presence-sync', 'peers' => $peers]);
}
}
public function onMessage(TcpConnection $from, string $raw): void
{
$msg = json_decode($raw); // records stay opaque: {} is relayed as {}
$room = $this->rooms[$from->context->roomKey ?? ''] ?? null;
if (! $room || ! $msg instanceof stdClass) {
return;
}
if (($msg->type ?? '') === 'append' && isset($msg->payload)) { // append, then fan out
$room->log[] = $msg->payload;
$room->broadcast($from, ['type' => 'record', 'from' => $from->context->id, 'payload' => $msg->payload]);
} elseif (($msg->type ?? '') === 'presence' && isset($msg->state) && strlen($raw) <= 4096) {
$from->context->state = $msg->state;
$room->broadcast($from, ['type' => 'presence', 'peer' => EditRoom::peer($from)]);
} // unknown types: ignore
}
public function onClose(TcpConnection $c): void
{
$room = $this->rooms[$c->context->roomKey ?? ''] ?? null;
if (! $room) {
return;
}
unset($room->clients[$c->id]);
$room->broadcast($c, ['type' => 'presence-leave', 'clientId' => $c->context->id]);
if (count($room->clients) === 0) { // the log lives until the room empties
$room->dropTimer = Timer::add(2, fn () => count($room->clients) === 0 && $this->drop($c->context->roomKey), [], false);
}
}
Live Example: Edit Forms Together
Run Server-Side Workflows
Some workflows need SurveyJS logic on the server. You may need to check a submission before storing it, find problems in a form JSON schema, create a PDF, or read answers from a document.
These operations run in Node.js. A Node.js backend can run the components directly; other backends can call a Node.js service over HTTP. In either case, your backend supplies the required data and uses the result in its own workflow.
Validate Responses
Browser validation helps respondents correct their answers while filling out a form. Your API can also receive requests that bypass the form, so you need to repeat validation on the server before storing a response.
When a submission arrives, load the form JSON schema from your storage. Validate the submitted answers against that schema on the server, checking required questions, value types, and allowed choices.
Save the response only if validation succeeds. If it fails, return the errors to the client so that the respondent can correct the answers.
Client code:
// Show the server's verdict in the completion message
survey.onComplete.add(async (sender, options) => {
options.showSaveInProgress();
const res = await fetch("/api/responses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ formId: "claim", data: sender.data })
});
if (res.ok) return options.showSaveSuccess();
const { errors, error } = await res.json();
options.showSaveError(errors ? errors.map(describe).join("; ") : error);
});
Server code:
// POST /api/responses — ask the SurveyJS service to validate, then store as usual
Route::post('/api/responses', function (Request $request) {
$body = json_decode($request->getContent(), flags: JSON_THROW_ON_ERROR); // the raw body keeps {} as {} (see I.1)
$definition = DB::table('forms')->where('key', $body->formId ?? null)->value('json') ?? abort(404, 'Unknown form');
$check = SurveyJsService::post('/response', ['schema' => json_decode($definition), 'response' => $body->data ?? null], timeout: 10);
if ($check->status() === 422 && $check->json('errors') !== null && ! array_key_exists('warnings', $check->json())) {
return response()->json(['errors' => $check->object()->errors], 400); // the answers don't fit the definition
}
if ($check->status() !== 200 || $check->body() !== '{}') { // fail closed: only exactly {} means valid
SurveyJsService::unexpected('/response', $check, $check->status() === 422 && $check->json('warnings') !== null ? 'The stored definition has errors' : 'SurveyJS service error');
}
$id = StoredJson::insertResponse($body->formId, $body->data); // stored as it arrived: 1.0 stays 1.0
return response()->json(['id' => $id], 201);
});
Live Example: Validate Responses
Lint JSON Schemas
A form JSON schema can contain mistakes that stop its questions or logic from working as intended. For example, a condition may refer to a question that does not exist. Check JSON schemas using the SurveyJS Linter before saving and return its findings to Survey Creator so that authors can fix these problems before respondents encounter them.
Client code:
// Creator: surface the server's lint report instead of a silent failure
async function saveDefinition(definition) {
const res = await fetch(`/api/forms/${formId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(definition)
});
const report = res.status === 204 ? {} : await res.json(); // 422 { errors, warnings } · 200 { warnings } · { error }
if (report.errors) creator.notify(report.errors.map(e => e.message).join("\n"), "error");
else if (report.warnings) creator.notify(report.warnings.map(e => e.message).join("\n"), "info");
else if (report.error) creator.notify(report.error, "error");
return res.ok;
}
creator.saveSurveyFunc = async (saveNo, callback) => callback(saveNo, await saveDefinition(creator.JSON));
Server code:
// PUT /api/forms/{id} — lint the definition from Creator before storing it
Route::put('/api/forms/{id}', function (Request $request, string $id) {
Gate::authorize('edit-forms');
$definition = json_decode($request->getContent(), flags: JSON_THROW_ON_ERROR); // the raw body keeps {} as {} (see III.1)
$lint = SurveyJsService::post('/schema', $definition, timeout: 10);
if ($lint->status() === 422 && $lint->json('errors') !== null) { // errors: refuse the save, report them as they are
return response($lint->body(), 422)->header('Content-Type', 'application/json');
}
$warningsOnly = $lint->status() === 200 && array_keys((array) $lint->json()) === ['warnings'];
if (! $warningsOnly && ($lint->status() !== 200 || $lint->body() !== '{}')) { // fail closed: nothing else counts as clean
SurveyJsService::unexpected('/schema', $lint);
}
StoredJson::put('forms', $id, $definition); // stored as it arrived: 1.0 stays 1.0
return $warningsOnly ? response()->json(['warnings' => $lint->object()->warnings]) : response()->noContent(); // warnings don't block the save
});
Live Example: Lint JSON Schemas
Generate PDFs
A backend workflow may need a PDF for an archive or email attachment. SurveyJS PDF Generator can create the document on the server without opening a browser.
Load the form JSON schema and, for a completed form, its saved answers. Pass them to PDF Generator to create the document. Your backend can then store the PDF, attach it to an email, or send it to the browser with the application/pdf content type.
Client code:
// Ask your server for the filled form as a PDF
async function openPdf(id) {
const tab = window.open("", "_blank"); // open the tab in the click itself: after an await, browsers block pop-ups
const res = await fetch(`/api/claims/${id}/pdf`);
if (!res.ok) {
tab.close();
throw new Error((await res.json()).error);
}
tab.location = URL.createObjectURL(await res.blob());
}
Server code:
// GET /api/claims/{id}/pdf — ask the SurveyJS service for the filled form and pass the PDF through
Route::get('/api/claims/{id}/pdf', function (string $id) {
$data = DB::table('responses')->where('form_id', 'claim')->where('id', $id)->value('data') ?? abort(404, 'Unknown claim');
$definition = DB::table('forms')->where('key', 'claim')->value('json');
$pdf = SurveyJsService::post('/pdf', ['schema' => json_decode($definition), 'response' => json_decode($data)], timeout: 60);
if ($pdf->status() !== 200 || ! str_starts_with($pdf->header('Content-Type'), 'application/pdf')) { // fail closed
SurveyJsService::unexpected('/pdf', $pdf);
}
return response($pdf->body(), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => "inline; filename=\"claim-{$id}.pdf\"",
]);
});
Extract Responses from Documents
Some responses arrive as completed paper forms, scans, or PDFs. AI Form Response Extractor can use your form JSON schema to read those documents and return answers in the same format as an online response.
Upload the document to your backend and send it, along with the JSON schema, to the extractor. The extractor calls the AI provider you configure and returns the answers with confidence information. It can also return a unique ID detected in the document, such as an ID from a QR code.
Use the returned answers to fill a draft form. Show which answers need checking so a person can review and correct them before submitting. Extraction itself does not save a response—save the reviewed answers through your usual endpoint.
Client code:
// Upload the scan; open the draft in the form for review
async function extract(file) {
const body = new FormData();
body.append("scan", file);
const res = await fetch("/api/work-orders/extract", { method: "POST", body });
const result = await res.json();
if (!res.ok) throw new Error(result.error);
survey.data = result.answers; // a person checks and corrects, then saves as usual
return result; // { answers, confidence, uniqueId }
}
Server code:
// POST /api/work-orders/extract — send the uploaded scan to the SurveyJS service; return a draft response
Route::post('/api/work-orders/extract', function (Request $request) {
$scan = $request->file('scan') ?? abort(400, 'Upload the scan in a field named "scan"');
abort_if(! $scan->isValid() || $scan->getSize() > 5 * 1024 * 1024, 413, 'The scan must be a file of 5 MB or less');
$out = SurveyJsService::post('/extract', [ // JSON with base64, not multipart
'schema' => json_decode(DB::table('forms')->where('key', 'work-order')->value('json')), 'document' => base64_encode($scan->get())], timeout: 120);
$result = $out->object();
return match (true) { // nothing is saved: a person reviews the draft
$out->status() === 200 && is_object($result) && is_object($result->data ?? null) => response()->json(
['answers' => $result->data, 'confidence' => $result->confidence ?? [], 'uniqueId' => $result->uniqueId ?? null]),
$out->status() === 400 && $out->json('error') === 'INVALID_DOCUMENT' => response()->json(['error' => 'The file is not a PDF, PNG, JPEG, WebP or GIF document'], 400),
$out->status() === 503 && $out->json('error') === 'AI_NOT_CONFIGURED' => response()->json(['error' => 'The SurveyJS service has no AI provider configured'], 503),
$out->status() === 502 && $out->json('error') === 'EXTRACTION_FAILED' => response()->json(['error' => 'Could not extract the response from the document'], 502),
default => SurveyJsService::unexpected('/extract', $out),
};
});
Send feedback to the SurveyJS team
Need help? Visit our support page