Skip to content

Forms written by admins

A form can be stored as a JSON schema in your database, so admins and clients can add or change forms without a developer. This guide builds the three parts of such a feature: an editor that refuses unsafe schemas, a public page that shows the form, and storage for the answers.

The schema

A schema lists fields and groups as data. Every option of a field class has a key:

{
  "name": "wedding",
  "fields": [
    { "type": "text", "name": "full_name", "label": "Full name", "rules": "required" },
    { "type": "select", "name": "guests", "label": "Number of guests",
      "options": { "small": "Up to 50", "large": "More than 50" }, "rules": "required" },
    { "type": "checkbox", "name": "contact_me", "label": "You may call me" },
    { "type": "submit", "label": "Send" }
  ]
}

row and fieldset groups take a children list, exactly like row() and fieldset() in a form class. FormSchema::describe() returns every type and key your policy allows, with an explanation of each, if you want to build a help page for admins.

One policy for the whole feature

A schema written by an admin is user input. It is checked against a policy, restricted by default, which blocks anything that could harm your site or its visitors: event-handler attributes, rules that read the database or can hang the server, a form action pointing elsewhere, unsafe uploads, password fields, and names that could overwrite your own columns.

Keep the policy in one place, so the editor and the public page always agree:

app/Libraries/FormPolicy.php
<?php

declare(strict_types=1);

namespace App\Libraries;

use Ephraitech\Forms\Schema\SchemaPolicy;

/**
 * What admins may put in the forms they write. Every place that checks or
 * loads an admin's schema uses this one policy, so the editor, the public
 * page and the submissions screen always agree.
 */
final class FormPolicy
{
    public static function forAdmins(): SchemaPolicy
    {
        return SchemaPolicy::restricted()
            // Names the app uses itself.
            ->forbidNames('form_definition_id', 'payload');
    }
}

Storage

app/Database/Migrations/2026-10-29-000400_CreateFormTables.php
<?php

declare(strict_types=1);

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class CreateFormTables extends Migration
{
    public function up(): void
    {
        $this->forge->addField([
            'id'         => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
            'title'      => ['type' => 'VARCHAR', 'constraint' => 150],
            'schema'     => ['type' => 'TEXT'],
            'created_at' => ['type' => 'DATETIME', 'null' => true],
            'updated_at' => ['type' => 'DATETIME', 'null' => true],
        ]);
        $this->forge->addPrimaryKey('id');
        $this->forge->createTable('form_definitions');

        $this->forge->addField([
            'id'                 => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
            'form_definition_id' => ['type' => 'INT', 'unsigned' => true],
            'payload'            => ['type' => 'TEXT'],
            'created_at'         => ['type' => 'DATETIME', 'null' => true],
            'updated_at'         => ['type' => 'DATETIME', 'null' => true],
        ]);
        $this->forge->addPrimaryKey('id');
        $this->forge->createTable('form_submissions');
    }

    public function down(): void
    {
        $this->forge->dropTable('form_submissions');
        $this->forge->dropTable('form_definitions');
    }
}
app/Models/FormDefinitionModel.php
<?php

declare(strict_types=1);

namespace App\Models;

use CodeIgniter\Model;

class FormDefinitionModel extends Model
{
    protected $table         = 'form_definitions';
    protected $returnType    = 'array';
    protected $allowedFields = ['title', 'schema'];
    protected $useTimestamps = true;
    protected array $casts   = ['schema' => 'json-array'];
}
app/Models/FormSubmissionModel.php
<?php

declare(strict_types=1);

namespace App\Models;

use CodeIgniter\Model;

class FormSubmissionModel extends Model
{
    protected $table         = 'form_submissions';
    protected $returnType    = 'array';
    protected $allowedFields = ['form_definition_id', 'payload'];
    protected $useTimestamps = true;
    protected array $casts   = ['payload' => 'json-array'];
}

The editor

FormSchema::check() checks what the admin typed without throwing, and lists every problem with its location, such as fields[2].rules: the "is_unique" rule is not allowed in this schema: it reads the database. Only a clean schema is saved:

app/Controllers/Admin/FormEditor.php
    public function save(int $id): RedirectResponse|string
    {
        $definition = $this->findOrFail($id);
        $json       = (string) $this->request->getPost('schema');
        $check      = FormSchema::check($json, FormPolicy::forAdmins());

        if (! $check->isValid()) {
            $this->response->setStatusCode(422);

            return view('admin/form_editor', [
                'definition' => $definition,
                'json'       => $json,
                'errors'     => $check->getMessages(),
            ]);
        }

        model(FormDefinitionModel::class)->update($id, ['schema' => $check->getSchema()]);

        return redirect()->to("admin/forms/{$id}/edit")->with('message', 'Form saved.');
    }

A "Check" button can call an endpoint that returns the same result as JSON:

app/Controllers/Admin/FormEditor.php
    /**
     * For a "Check" button in the editor: JSON in, JSON out.
     */
    public function check(): ResponseInterface
    {
        $check = FormSchema::check((string) $this->request->getPost('schema'), FormPolicy::forAdmins());

        return $this->response->setJSON($check->toArray() + [
            'messages' => $check->getMessages(),
            'csrf'     => csrf_hash(),   // CodeIgniter renews the token after each request
        ]);
    }
The complete editor controller and view
app/Controllers/Admin/FormEditor.php
<?php

declare(strict_types=1);

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Libraries\FormPolicy;
use App\Models\FormDefinitionModel;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\ResponseInterface;
use Ephraitech\Forms\Schema\FormSchema;

class FormEditor extends BaseController
{
    public function edit(int $id): string
    {
        $definition = $this->findOrFail($id);

        return view('admin/form_editor', [
            'definition' => $definition,
            'json'       => json_encode($definition['schema'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
            'errors'     => [],
        ]);
    }

    public function save(int $id): RedirectResponse|string
    {
        $definition = $this->findOrFail($id);
        $json       = (string) $this->request->getPost('schema');
        $check      = FormSchema::check($json, FormPolicy::forAdmins());

        if (! $check->isValid()) {
            $this->response->setStatusCode(422);

            return view('admin/form_editor', [
                'definition' => $definition,
                'json'       => $json,
                'errors'     => $check->getMessages(),
            ]);
        }

        model(FormDefinitionModel::class)->update($id, ['schema' => $check->getSchema()]);

        return redirect()->to("admin/forms/{$id}/edit")->with('message', 'Form saved.');
    }

    /**
     * For a "Check" button in the editor: JSON in, JSON out.
     */
    public function check(): ResponseInterface
    {
        $check = FormSchema::check((string) $this->request->getPost('schema'), FormPolicy::forAdmins());

        return $this->response->setJSON($check->toArray() + [
            'messages' => $check->getMessages(),
            'csrf'     => csrf_hash(),   // CodeIgniter renews the token after each request
        ]);
    }

    private function findOrFail(int $id): array
    {
        return model(FormDefinitionModel::class)->find($id) ?? throw PageNotFoundException::forPageNotFound();
    }
}
app/Views/admin/form_editor.php
<h1>Edit form: <?= esc($definition['title']) ?></h1>

<?php if (session('message')): ?>
    <div class="alert alert-success"><?= esc(session('message')) ?></div>
<?php endif ?>

<?php if ($errors !== []): ?>
    <div class="alert alert-danger">
        <p>The form was not saved:</p>
        <ul>
            <?php foreach ($errors as $error): ?>
                <li><code><?= esc($error) ?></code></li>
            <?php endforeach ?>
        </ul>
    </div>
<?php endif ?>

<form method="post" action="<?= site_url('admin/forms/' . $definition['id']) ?>">
    <?= csrf_field() ?>
    <textarea name="schema" rows="20" class="form-control font-monospace"><?= esc($json) ?></textarea>
    <button type="submit" class="btn btn-primary mt-3">Save</button>
</form>

The public page

Load the saved schema with the same policy. If it no longer passes, because it was saved before you tightened the policy or edited in the database directly, loading throws: show a short notice instead of the form and log the details for your admins.

app/Controllers/Forms.php
    private function load(array $definition): ?Form
    {
        try {
            $form = Form::fromSchema($definition['schema'], policy: FormPolicy::forAdmins());
        } catch (SchemaException $e) {
            // Saved before the policy changed, or edited in the database directly.
            log_message('error', 'Form {id} cannot be shown: {errors}', ['id' => $definition['id'], 'errors' => implode('; ', $e->getMessages())]);

            return null;
        }

        // The application decides where forms are sent; schemas cannot.
        return $form->action('forms/' . $definition['id']);
    }

The application sets where the form is sent; a schema cannot.

Answers are stored as one JSON payload, never mapped onto columns, so a field an admin adds can never write to data it should not touch:

app/Controllers/Forms.php
    public function submit(int $id): RedirectResponse|string
    {
        $definition = $this->findOrFail($id);
        $form       = $this->load($definition);

        if ($form === null) {
            return $this->unavailable();
        }

        if (! $form->validate()) {
            return view('forms/show', ['title' => $definition['title'], 'form' => $form]);
        }

        // The admin's fields are stored as one JSON payload, never as columns.
        model(FormSubmissionModel::class)->insert([
            'form_definition_id' => $id,
            'payload'            => $form->validated(),
        ]);

        return redirect()->to("forms/{$id}", 303)->with('message', 'Thank you, we received your answers.');
    }
The complete public controller, views and routes
app/Controllers/Forms.php
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Libraries\FormPolicy;
use App\Models\FormDefinitionModel;
use App\Models\FormSubmissionModel;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse;
use Ephraitech\Forms\Form;
use Ephraitech\Forms\Schema\SchemaException;

/**
 * Public pages for forms written by admins.
 */
class Forms extends BaseController
{
    public function show(int $id): string
    {
        $definition = $this->findOrFail($id);
        $form       = $this->load($definition);

        return $form === null ? $this->unavailable() : view('forms/show', ['title' => $definition['title'], 'form' => $form]);
    }

    public function submit(int $id): RedirectResponse|string
    {
        $definition = $this->findOrFail($id);
        $form       = $this->load($definition);

        if ($form === null) {
            return $this->unavailable();
        }

        if (! $form->validate()) {
            return view('forms/show', ['title' => $definition['title'], 'form' => $form]);
        }

        // The admin's fields are stored as one JSON payload, never as columns.
        model(FormSubmissionModel::class)->insert([
            'form_definition_id' => $id,
            'payload'            => $form->validated(),
        ]);

        return redirect()->to("forms/{$id}", 303)->with('message', 'Thank you, we received your answers.');
    }

    private function load(array $definition): ?Form
    {
        try {
            $form = Form::fromSchema($definition['schema'], policy: FormPolicy::forAdmins());
        } catch (SchemaException $e) {
            // Saved before the policy changed, or edited in the database directly.
            log_message('error', 'Form {id} cannot be shown: {errors}', ['id' => $definition['id'], 'errors' => implode('; ', $e->getMessages())]);

            return null;
        }

        // The application decides where forms are sent; schemas cannot.
        return $form->action('forms/' . $definition['id']);
    }

    private function unavailable(): string
    {
        $this->response->setStatusCode(503);

        return view('forms/unavailable');
    }

    private function findOrFail(int $id): array
    {
        return model(FormDefinitionModel::class)->find($id) ?? throw PageNotFoundException::forPageNotFound();
    }
}
app/Views/forms/show.php
<h1><?= esc($title) ?></h1>

<?php if (session('message')): ?>
    <div class="alert alert-success"><?= esc(session('message')) ?></div>
<?php endif ?>

<?= $form ?>
app/Views/forms/unavailable.php
<h1>Form unavailable</h1>

<p>This form is being updated. Please try again later.</p>
app/Config/Routes.php
<?php

/** @var \CodeIgniter\Router\RouteCollection $routes */
$routes->get('forms/(:num)', 'Forms::show/$1');
$routes->post('forms/(:num)', 'Forms::submit/$1');

$routes->group('admin', static function ($routes): void {
    $routes->get('forms/(:num)/edit', 'Admin\FormEditor::edit/$1');
    $routes->post('forms/(:num)', 'Admin\FormEditor::save/$1');
    $routes->post('forms/check', 'Admin\FormEditor::check');
    $routes->get('forms/(:num)/answers', 'Admin\Submissions::index/$1');
});

Adjusting the policy

The restricted defaults suit public forms. Change them with methods that each return a new policy:

SchemaPolicy::restricted()
    ->allowTypes('money')                              // a registered custom field type
    ->allowRules(['valid_ip' => SchemaPolicy::PARAM_NONE])
    ->forbidNames('customer_id')                       // more of your own columns
    ->disallowProviders('staff')                       // keep internal lists off public forms
    ->withMaxUploadSize(20480);                        // kilobytes

Changes that would reopen a hole, such as allowing onclick attributes or SVG uploads, are refused.

Schemas that developers write and commit with the code can use SchemaPolicy::trusted(), which only checks their structure:

$form = Form::fromJson(file_get_contents(APPPATH . 'Forms/enquiry.json'), policy: SchemaPolicy::trusted());

php spark forms:schema check path/to/schema.json checks a schema file from the command line, and php spark forms:schema export "App\Forms\BookingForm" turns a form class into a schema, a good starting point for admins.

Common mistakes

Loading admin schemas with the trusted policy. The trusted policy allows everything. Use it only for schemas in your code repository.

Using different policies in different places. If the editor allows something the public page does not, a saved form goes offline. Keep one policy class, as in the example.

Saving answers into columns. An admin could add a field named like one of your columns. Store answers as a payload.

Showing schema errors to visitors. They describe your configuration. Log them and show a neutral notice, as the example does.