Getting started¶
In this tutorial you build appointment booking for a small business: visitors book an appointment, staff see a list of bookings, and anyone can change a booking. Along the way you use the ideas that the rest of the package builds on. It takes about 20 minutes.
By the end you will know how to:
- define a form as a class, with rules, options and a two-column layout
- show it, validate it, and save only clean data
- add a rule that validation rules cannot express
- reuse the same form to edit a saved record
Before you start
You need a CodeIgniter 4.7 app with the package installed and a working database connection. The views below are kept minimal; in your app they would sit inside your layout, which should load Bootstrap 5.
1. Create the table¶
Bookings need a table. Create a migration:
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateAppointments extends Migration
{
public function up(): void
{
$this->forge->addField([
'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'full_name' => ['type' => 'VARCHAR', 'constraint' => 100],
'phone' => ['type' => 'VARCHAR', 'constraint' => 20],
'email' => ['type' => 'VARCHAR', 'constraint' => 150, 'null' => true],
'service' => ['type' => 'VARCHAR', 'constraint' => 20],
'appointment_date' => ['type' => 'DATE'],
// Stored as "HH:MM" text, exactly what the browser's time picker sends.
'appointment_time' => ['type' => 'VARCHAR', 'constraint' => 5],
'notes' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('id');
$this->forge->createTable('appointments');
}
public function down(): void
{
$this->forge->dropTable('appointments');
}
}
The time is stored as HH:MM text because that is exactly what a browser's
time picker sends, so the value goes into the database and back into the form
unchanged.
Run it:
And add a model for the table:
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
class AppointmentModel extends Model
{
protected $table = 'appointments';
protected $returnType = 'array';
protected $allowedFields = ['full_name', 'phone', 'email', 'service', 'appointment_date', 'appointment_time', 'notes'];
protected $useTimestamps = true;
}
2. Define the form¶
A form is a class with a define() method that adds its fields:
<?php
declare(strict_types=1);
namespace App\Forms;
use Ephraitech\Forms\Form;
class AppointmentForm extends Form
{
public const SERVICES = [
'fitting' => 'Suit fitting',
'alterations' => 'Alterations',
'consultation' => 'Style consultation',
];
protected function define(): void
{
$this->row(function (): void {
$this->text('full_name', 'Full name')
->rules('required|min_length[3]|max_length[100]')
->width(6);
$this->tel('phone', 'Phone number')
->rules('required|max_length[20]')
->placeholder('0772 123 456')
->width(6);
});
$this->email('email', 'Email address')
->rules('permit_empty|valid_email|max_length[150]')
->help('Optional. We send a reminder here.');
$this->row(function (): void {
$this->select('service', 'Service', self::SERVICES)
->placeholder('Choose a service')
->rules('required')
->width(6);
$this->date('appointment_date', 'Date')
->rules('required|valid_date[Y-m-d]')
->width(3);
$this->time('appointment_time', 'Time')
->rules('required|valid_date[H:i]')
->min('09:00')
->max('17:00')
->width(3);
});
$this->textarea('notes', 'Notes')
->rows(3)
->rules('permit_empty|max_length[500]');
$this->submit('Book appointment');
}
}
Each part does a specific job:
- Fields and labels
text(),tel(),email(),select(),date(),time()andtextarea()each add a field. The first argument is the name used in the request and the database; the second is the label people see. Leave the label out and it is made from the name:full_namebecomes "Full name".- Rules
rules()takes CodeIgniter's own validation rules. The package also turns them into HTML attributes, sorequired|max_length[100]addsrequiredandmaxlength="100"to the input, which helps screen readers and mobile keyboards. Validation still always happens on the server.- Options
- The service dropdown lists three options from a constant, which the list
page reuses later. You do not need an
in_listrule: a value that is not one of the options is rejected automatically, even if someone changes the page with their browser's developer tools. - Layout
row()puts the fields defined inside it side by side, andwidth()sets how many of 12 columns each takes. Name and phone share the first row; the service, date and time share the second. On phones the columns stack.- Extras
placeholder(),help(),rows(), andmin()/max()on the time picker shape how fields look and behave.
Why a class?
The same form is used to book and to change an appointment. Defining it once means the fields, rules and layout can never drift apart between the two pages.
3. Add the routes¶
Add these routes to app/Config/Routes.php:
<?php
/** @var \CodeIgniter\Router\RouteCollection $routes */
$routes->get('appointments', 'Appointments::index');
$routes->get('appointments/new', 'Appointments::new');
$routes->post('appointments', 'Appointments::create');
$routes->get('appointments/(:num)/edit', 'Appointments::edit/$1');
$routes->patch('appointments/(:num)', 'Appointments::update/$1');
The last route uses PATCH, the HTTP method for changing part of a record.
Browsers can only send GET and POST, so the package handles this for you,
as step 7 shows.
4. Book an appointment¶
Create the controller with the two booking actions. new() shows the empty
form; create() handles what was submitted:
public function new(): string
{
return $this->showForm($this->createForm(), 'Book an appointment');
}
public function create(): RedirectResponse|string
{
$form = $this->createForm();
if (! $form->validate() || ! $this->isBookable($form)) {
return $this->showForm($form, 'Book an appointment');
}
model(AppointmentModel::class)->insert($form->validated());
return redirect()->to('/appointments')->with('message', 'Appointment booked.');
}
Two small helpers keep the actions short. The first builds the form for booking and sets where it is sent:
private function createForm(): AppointmentForm
{
return (new AppointmentForm())->action('appointments');
}
private function editForm(int $id): AppointmentForm
{
$form = (new AppointmentForm())
->method('patch')
->action('appointments/' . $id);
$form->getField('submit')->label('Save changes');
return $form;
}
The second shows the form page. Its partner, findOrFail(), is used in step 7:
private function findOrFail(int $id): array
{
$appointment = model(AppointmentModel::class)->find($id);
if ($appointment === null) {
throw PageNotFoundException::forPageNotFound();
}
return $appointment;
}
private function showForm(AppointmentForm $form, string $title): string
{
return view('appointments/form', ['form' => $form, 'title' => $title]);
}
The view prints the whole form with one line:
<h1><?= esc($title) ?></h1>
<?= $form ?>
<p><a href="<?= site_url('appointments') ?>">Back to appointments</a></p>
That line renders the labels, the inputs in their rows, the CSRF field, error messages under each field, and whatever the visitor typed when the form comes back with errors.
Here is what happens in create():
validate()reads the submitted data and checks every rule. If anything fails, the page is shown again with the same form object, which now knows the submitted values and the errors.validated()returns only the fields the form defines, ready for the model. Strings are trimmed and empty optional fields becomenull. Any extra field someone adds to the request, such asis_admin, is dropped.
Try it. Open /appointments/new and submit the form empty: each required
field shows its error. Fill in your name, leave the phone empty and submit
again: your name is still there. Then book a real appointment.
5. Add a business rule¶
Validation rules check each value on its own. Some rules need more: an
appointment must not be in the past, or a time slot must be free. The
isBookable() method that create() calls handles this:
private function isBookable(AppointmentForm $form): bool
{
if ($form->validated()['appointment_date'] < date('Y-m-d')) {
$form->setError('appointment_date', 'Choose today or a later date.');
}
return $form->isValid();
}
It runs only after validation passes, so validated() is safe to use.
setError() adds an error to a field exactly like a failed rule: it appears
under the date input, and isValid() now returns false, so create() shows
the form again. A check against the database for double bookings would go here
too.
Try it. Book an appointment for a date last year.
6. List the appointments¶
Add the list action to the controller:
public function index(): string
{
return view('appointments/index', [
'appointments' => model(AppointmentModel::class)
->orderBy('appointment_date')
->orderBy('appointment_time')
->findAll(),
]);
}
And its view:
<?php use App\Forms\AppointmentForm; ?>
<h1>Appointments</h1>
<?php if (session('message')): ?>
<div class="alert alert-success"><?= esc(session('message')) ?></div>
<?php endif ?>
<p><a href="<?= site_url('appointments/new') ?>" class="btn btn-primary">Book an appointment</a></p>
<?php if ($appointments === []): ?>
<p>No appointments yet.</p>
<?php else: ?>
<table class="table">
<thead>
<tr><th>Date</th><th>Time</th><th>Name</th><th>Service</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($appointments as $appointment): ?>
<tr>
<td><?= esc($appointment['appointment_date']) ?></td>
<td><?= esc($appointment['appointment_time']) ?></td>
<td><?= esc($appointment['full_name']) ?></td>
<td><?= esc(AppointmentForm::SERVICES[$appointment['service']] ?? $appointment['service']) ?></td>
<td><a href="<?= site_url('appointments/' . $appointment['id'] . '/edit') ?>">Change</a></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
<?php endif ?>
The table shows "Suit fitting" rather than the stored fitting, by looking the
value up in the form's SERVICES constant.
7. Change an appointment¶
The edit actions reuse the form:
public function edit(int $id): string
{
$form = $this->editForm($id)->fill($this->findOrFail($id));
return $this->showForm($form, 'Change appointment');
}
public function update(int $id): RedirectResponse|string
{
$this->findOrFail($id);
$form = $this->editForm($id);
if (! $form->validate() || ! $this->isBookable($form)) {
return $this->showForm($form, 'Change appointment');
}
model(AppointmentModel::class)->update($id, $form->validated());
// 303 tells any client to load the list with GET. Without it,
// CodeIgniter answers a genuine PATCH with 307, which repeats the PATCH.
return redirect()->to('/appointments', 303)->with('message', 'Appointment changed.');
}
The form for editing comes from editForm(), which you added in step 4 with
createForm(). It differs in three ways:
method('patch')sends the form asPATCH. The browser still sendsPOST, with a hidden_methodfield that CodeIgniter's router understands.action()points at this booking's address.- The button says "Save changes" instead of "Book appointment".
In edit(), fill() puts the saved booking into the form. When the form is
submitted and comes back with an error, it shows what the visitor submitted,
not the saved values, so nothing they typed is lost. update() checks the
booking exists before doing anything, then validates and saves exactly like
create().
Why the 303?
After a change, update() redirects with status 303, "See Other", which
tells any client to load the list with GET. CodeIgniter would otherwise
answer a genuine PATCH request (from JavaScript or an API client) with
307, which asks the client to repeat the PATCH at the new address.
Try it. Open the list, choose Change on your booking, pick another service and save.
The complete controller¶
app/Controllers/Appointments.php
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Forms\AppointmentForm;
use App\Models\AppointmentModel;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse;
class Appointments extends BaseController
{
public function index(): string
{
return view('appointments/index', [
'appointments' => model(AppointmentModel::class)
->orderBy('appointment_date')
->orderBy('appointment_time')
->findAll(),
]);
}
public function new(): string
{
return $this->showForm($this->createForm(), 'Book an appointment');
}
public function create(): RedirectResponse|string
{
$form = $this->createForm();
if (! $form->validate() || ! $this->isBookable($form)) {
return $this->showForm($form, 'Book an appointment');
}
model(AppointmentModel::class)->insert($form->validated());
return redirect()->to('/appointments')->with('message', 'Appointment booked.');
}
public function edit(int $id): string
{
$form = $this->editForm($id)->fill($this->findOrFail($id));
return $this->showForm($form, 'Change appointment');
}
public function update(int $id): RedirectResponse|string
{
$this->findOrFail($id);
$form = $this->editForm($id);
if (! $form->validate() || ! $this->isBookable($form)) {
return $this->showForm($form, 'Change appointment');
}
model(AppointmentModel::class)->update($id, $form->validated());
// 303 tells any client to load the list with GET. Without it,
// CodeIgniter answers a genuine PATCH with 307, which repeats the PATCH.
return redirect()->to('/appointments', 303)->with('message', 'Appointment changed.');
}
private function createForm(): AppointmentForm
{
return (new AppointmentForm())->action('appointments');
}
private function editForm(int $id): AppointmentForm
{
$form = (new AppointmentForm())
->method('patch')
->action('appointments/' . $id);
$form->getField('submit')->label('Save changes');
return $form;
}
private function isBookable(AppointmentForm $form): bool
{
if ($form->validated()['appointment_date'] < date('Y-m-d')) {
$form->setError('appointment_date', 'Choose today or a later date.');
}
return $form->isValid();
}
private function findOrFail(int $id): array
{
$appointment = model(AppointmentModel::class)->find($id);
if ($appointment === null) {
throw PageNotFoundException::forPageNotFound();
}
return $appointment;
}
private function showForm(AppointmentForm $form, string $title): string
{
return view('appointments/form', ['form' => $form, 'title' => $title]);
}
}
What you learned¶
- A form is a class whose
define()adds fields; one class serves both the create and the edit page. - Rules are CodeIgniter's own, and choices are checked against their options automatically.
row()andwidth()lay fields out in columns without touching the view.<?= $form ?>renders everything, including errors and the visitor's input.validate()checks a submission,validated()returns only clean, defined fields, andsetError()adds errors from your own checks.fill()loads saved data for editing, andmethod('patch')sends the form asPATCH.
This tutorial is tested
The files in this tutorial are real files in the package's repository. The test suite runs the booking, listing, the date rule and editing over HTTP against a database, so the code works with the version you install.
Continue with the guides: layouts, multiple choices, file uploads, custom field types and customising the views. The home page lists everything the package covers.