SurveyJS Server Integration · PHP / Laravel I.8 · Fill one form together
You work in a private copy of the demo database, kept until it has been unused for 24 hours.

I.8

Fill one form together

You need this: When several people complete one record: a dispatcher and a technician on a job sheet, an applicant and an adviser.

Several people fill in the same response at once and see each other's answers, focus and cursors. On the client it is one plugin, CollaborationPlugin from survey-core/collaboration (v3.1.1+). It emits small messages, applies incoming ones, and draws the participant bar, focus rings and cursors itself. Your server is a relay: it keeps each room's definition and the latest value of every answer, sends both to a newcomer in one init message, stores and forwards each change, and passes presence through. It never parses the definition or the answers. Last write wins per question. Saving the finished response stays the I.1 endpoint.

This relay is app/Relay/FillRelay.php, run as its own process with php artisan relay:fill (port 8081).

This step on the Server Integration page Source on GitHub

Try this

  1. Open a second window with the invite link. Both participants appear in the participant bar, with focus rings and cursors. Switch the demo user in one window to see Alice and Bob side by side.
  2. Answer a few questions, then open a third window: it starts with those answers, from one init message.
  3. Type in the same question from two windows: the last write wins.
  4. Press Complete in one window: it posts through I.1, POST /api/responses in Requests.
  5. "What the server stored" shows the room's latest values as the relay keeps them (snapshots are written in demo mode).
Record
Invite link

The link carries your sandbox id: whoever opens it works in your copy of the demo data, in the same rooms.

Code that just ran

These regions are read from the files that served this page, the same lines the Server Integration page shows.

Client · shared/client/fill-together.js · sjs:I.8.client View on GitHub
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 · app/Relay/FillRelay.php · sjs:I.8.server View on GitHub
// 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);
    }
}

Definition: shared/definitions/fill-together.json