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:
<?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¶
<?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');
}
}
<?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'];
}
<?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:
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:
/**
* 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
<?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();
}
}
<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.
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:
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
<?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();
}
}
<h1><?= esc($title) ?></h1>
<?php if (session('message')): ?>
<div class="alert alert-success"><?= esc(session('message')) ?></div>
<?php endif ?>
<?= $form ?>
<h1>Form unavailable</h1>
<p>This form is being updated. Please try again later.</p>
<?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.
Related¶
- Showing submissions to read the stored answers
- Option providers for
options_from