Build a PHP Task Board with PDO
Build a PHP task board that stores, searches, and updates PostgreSQL tasks.
Introduction
30 Second Summary
A task can look safely stored until one refresh makes it disappear. That moment reveals whether the page truly remembers anything.
In this project, you will build a local task board with PHP that saves form submissions through PDO into PostgreSQL. You will also search saved titles while keeping browser input separate from database queries.
What You'll Build
You'll add a task in Firefox ESR. Then you'll refresh the page and see the same database-backed task waiting for you.
By the end of this project, you'll have:
- A working task board where you submit a title and see the saved task appear in your browser.
- A persistent task list that keeps its rows after a page refresh or a development server restart.
- A safe prepared search that filters task titles while keeping browser input separate from SQL.
- Secret Mission: Add a prepared update action that marks one task complete and changes its visual status.
Are there any prerequisites?
You need a PostgreSQL database name and login with permission to create a table. No prior PHP application experience is required.
Before We Start
This step locks in the goal before any code is written. PostgreSQL rows will preserve task titles after each browser request resets PHP variables.
Set Up Your Local Workspace
Your task board will soon move a title from the browser into PHP. PostgreSQL will keep that title beyond one browser request.
A missing CLI can make later failures hard to place. A missing database driver creates the same problem. This step checks each layer before you build the task flow.
In this step, get ready to:
- Verify the PHP CLI and PostgreSQL driver.
- Create the local workspace and starter page.
- Run the PHP development server and load the page in Firefox ESR.
Verify PHP and Firefox ESR
The PHP CLI runs your local setup commands. PDO uses the pdo_pgsql module to communicate with PostgreSQL. Firefox ESR gives you a browser for testing each request.
- Check the installed PHP version by running this command in your terminal:
php -v
What Does the Version Check Show?
The command prints the version used by the PHP CLI. Debian 13 includes PHP 8.4 as its baseline.
✔️ I see the expected PHP version
The first output line reports PHP 8.4 or higher. Your CLI is ready for the project.
ⓧ I see an older PHP version
This project targets the PHP 8.4 baseline included with Debian 13.
- Confirm that your machine uses Debian 13.
- Ask your device administrator to update the Debian PHP packages before continuing.
- Return to this check after the CLI reports PHP 8.4 or higher.
ⓧ The command is unavailable
The terminal cannot find the PHP CLI. The project needs the Debian 13 PHP baseline before it can run.
- Ask your device administrator to restore the PHP CLI from your Debian setup.
- Return to this check after the PHP version command runs successfully.
- List the loaded PHP modules by running this command:
php -m
What Does the Module Check Show?
The output lists modules that the PHP CLI can use. Look for pdo_pgsql on its own line.
That entry confirms PHP has the PostgreSQL PDO driver. The later connection code depends on this bridge.
You should see pdo_pgsql in the module list.
Missing the PostgreSQL Driver?
Confirm that you ran the module check with the same PHP CLI shown by the version check. Ask your device administrator to enable the PostgreSQL PDO driver when the module is absent.
Need help tracing the missing module?
- Check whether Firefox ESR is available by running this command:
firefox-esr -v
What Does the Browser Check Show?
The command prints the installed Firefox ESR version. A version line confirms that the browser executable is available.
✔️ I see a Firefox ESR version
Firefox ESR is available. You can use it to test the local task board.
ⓧ The browser is unavailable
Installing a system package requires administrator access. This change adds Debian's Firefox ESR package without changing your project files.
- Switch to a terminal session with root privileges.
- Refresh the package information and install Firefox ESR by running these commands:
apt update
apt install firefox-esr
What Do These Commands Do?
- The first command refreshes the package information used by Debian.
- The second command installs Firefox ESR from Debian's repositories.
- Run the Firefox ESR version check again after the installation completes.
Browser Installation Failed?
Confirm that the terminal session has root privileges. Check that the machine can reach Debian's package repositories.
Help me diagnose the Firefox ESR installation.
Your command line tools are ready. PHP can load the PostgreSQL driver. Firefox ESR is available for the browser checks ahead.
Create the starter project
The pdo-task-board folder holds the whole project. Its htdocs subfolder acts as the local web document root.
- Move to your Desktop by running this command in the terminal:
cd ~/Desktop
Why Start on the Desktop?
This gives the project a predictable location. You can find the folder from the terminal or your editor without searching the whole machine.
- Create the project folders and list the new project folder by running these commands:
mkdir -p pdo-task-board/htdocs
ls pdo-task-board
What Do These Commands Create?
- The first command creates pdo-task-board with an htdocs subfolder.
- The second command lists the contents of pdo-task-board so you can confirm the subfolder exists.
You should see htdocs in the terminal output.
Folder Missing from the Output?
Check that your terminal is on the Desktop. Confirm that the folder names use lowercase letters and hyphens exactly as shown.
Help me find my project folders.
The starter page gives the local server one valid PHP document to return. Its heading creates a quick visual check before any form or database logic exists.
- Use Debian's application search to find Sublime Text.
- Select Sublime Text from the search results.
- Click File in the top menu.
- Click Open Folder….
- Select the pdo-task-board folder from your Desktop.
- Confirm the folder selection.
- Click File in the top menu.
- Click New File.
- Create the starter page by pasting this code into the empty file:
<?php
declare(strict_types=1);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PDO Task Board</title>
</head>
<body>
<h1>PDO Task Board</h1>
</body>
</html>
What Does This Page Do?
- The PHP declaration enables strict type checking for future PHP code.
- The document structure gives the browser valid HTML to render.
- The heading provides the visible text used in this step's browser check.
- Click File in the top menu.
- Click Save.
- Select the htdocs folder in the save dialog.
- Enter index.php as the file name.
- Confirm the save action.
- Check the Sublime Text sidebar for htdocs/index.php.
File Saved in the Wrong Folder?
Check the sidebar hierarchy. The index.php file must sit inside pdo-task-board/htdocs.
Help me place the starter file correctly.
✔️ Awesome, I've got everything!
Great. Double-check that index.php is saved inside pdo-task-board/htdocs.
ⓧ I'd like to double check the full code
<?php
declare(strict_types=1);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PDO Task Board</title>
</head>
<body>
<h1>PDO Task Board</h1>
</body>
</html>
Start the local server
The PHP development server treats htdocs as the document root when you start it from that folder. Requests to the local URL can then reach index.php.
- Switch back to your terminal.
- Move into the web document folder by running this command:
cd pdo-task-board/htdocs
Why Does the Folder Matter?
The server uses the current folder as the source for browser requests. Starting it from htdocs keeps the rest of the project outside the served document root.
Before you start the server, what heading do you expect the browser to show?
- Start the local PHP development server by running this command:
php -S localhost:8000
What Does the Server Command Do?
The command starts a development server on localhost:8000. It keeps the current terminal busy while the server runs.
Leave this terminal running for the rest of the project. Press Ctrl+C when you eventually need to stop the server.
You should see a terminal message confirming that the development server has started.
Server Did Not Start?
Confirm that the terminal is inside pdo-task-board/htdocs. A port conflict means another process may already be using localhost:8000.
Help me diagnose the local server.
- Use Debian's application search to find Firefox ESR.
- Select Firefox ESR from the search results.
Before you load the local address, what page content should prove that Firefox reached the correct document root?
- Click the Firefox ESR address bar.
- Enter http://localhost:8000/.
- Press Enter.
You should see PDO Task Board as the page heading. The terminal should also record a browser request.
Keep This Server Local
PHP's built-in server is designed for local development and controlled demonstrations. Keep it on localhost throughout this project.
Starter Page Not Loading?
Keep the server terminal running. Confirm that Firefox ESR uses the local URL shown above.
Check that index.php is saved inside pdo-task-board/htdocs.
Help me connect Firefox to my local PHP page.
That is the workspace ready. Your browser can now reach PHP through the local server. Next, you will build a task form that works for one request.
Build a Temporary Task Board
Your starter page proves that Firefox ESR can reach PHP through the local development server. A task board also needs a way to receive a title from the browser.
This version sends a title through a form into a request-local PHP array. A refresh then tests what survives into the next request.
In this step, get ready to:
- Build a form that submits a task title to index.php.
- Process one submitted title with a request-local PHP array.
- Test what happens to the title after a page refresh.
Add the task form
An HTML form packages browser input for the server. The name="title" attribute becomes the key that PHP receives.
- Switch back to htdocs/index.php in Sublime Text.
- Find the <h1>PDO Task Board</h1> line.
- Add the form directly below the heading by copying this code:
<form method="post">
<label for="title">New task</label>
<input id="title" name="title" required>
<button type="submit">Add task</button>
</form>
What does this form do?
- The method="post" attribute sends the form values in a POST request.
- The name="title" attribute gives the submitted value its server-side key.
- The required attribute asks the browser to block an empty submission.
- Save htdocs/index.php.
- Return to Firefox ESR.
- Refresh the page at http://localhost:8000/.
You'll see a New task field with an Add task button below the heading.
Form not showing?
- Confirm that you saved htdocs/index.php.
- Check that the form sits between the heading and the closing body tag.
- Confirm that the PHP development server is still running in the terminal from earlier.
Help me find why my PHP form is missing from the page.
Read and display the submitted title
An HTTP POST request makes form values available through $_POST. The page can trim the submitted title before placing a non-empty value in $tasks.
- Return to htdocs/index.php in Sublime Text.
- Replace the opening PHP block above <!doctype html> with this code:
<?php
declare(strict_types=1);
$tasks = [];
$title = trim((string) ($_POST['title'] ?? ''));
if ($title !== '') {
$tasks[] = $title;
}
?>
How does the request handling work?
- The $tasks array starts empty each time PHP runs the page.
- The $_POST['title'] expression reads the value associated with the form field.
- The trim() function removes whitespace from the edges of the submitted title.
- The condition adds the title only when a value remains after trimming.
- Find the closing </form> tag.
- Add the rendering loop directly below the form by copying this code:
<?php foreach ($tasks as $task): ?>
<p><?= htmlspecialchars($task, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p>
<?php endforeach; ?>
How does the task reach the page?
- The foreach loop renders one paragraph for each value in $tasks.
- The htmlspecialchars() function escapes special characters before the title reaches the HTML.
- The escaping flags handle quotes and invalid character sequences.
- Save htdocs/index.php.
- Return to Firefox ESR.
- Enter Learn PDO in the New task field.
- Click Add task.
Firefox ESR reloads the page. You'll see Learn PDO below the form.
That is your first full browser-to-PHP form flow working.
Task not appearing?
- Check that the input still uses name="title".
- Confirm that the rendering loop sits below the form.
- Check that $tasks[] = $title remains inside the non-empty title condition.
Help me debug why my submitted task does not appear.
✔️ Awesome, I've got everything!
Your complete temporary board is now in htdocs/index.php.
ⓧ I'd like to double check the full code
<?php
declare(strict_types=1);
$tasks = [];
$title = trim((string) ($_POST['title'] ?? ''));
if ($title !== '') {
$tasks[] = $title;
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PDO Task Board</title>
</head>
<body>
<h1>PDO Task Board</h1>
<form method="post">
<label for="title">New task</label>
<input id="title" name="title" required>
<button type="submit">Add task</button>
</form>
<?php foreach ($tasks as $task): ?>
<p><?= htmlspecialchars($task, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p>
<?php endforeach; ?>
</body>
</html>
Watch the task disappear
The visible task proves that one submission reached PHP. A fresh request now tests whether that value lasts.
Before you refresh, do you think Learn PDO will still be listed when PHP handles the next request?
- Refresh the current page in Firefox ESR.
You'll see the form again. The Learn PDO task is gone.
Why did the task disappear?
PHP runs index.php from the beginning for every request. The $tasks = [] line creates a new empty array during the refresh.
The submitted title lived only for the request that processed the form. Persistent storage must keep the value after that request ends.
You found the exact persistence gap this project is built to solve.
Your form flow works from browser input to escaped output. Next, you'll give the task board durable storage with PDO and PostgreSQL.
Connect PDO and Create Storage
Your browser form now proves that input reaches PHP. A refresh also proved that $tasks lives for only one request.
The disappearing task needs durable storage. In this step, you connect PDO to PostgreSQL to create a table that survives browser requests.
In this step, get ready to:
- Move your database settings outside the browser-facing htdocs folder.
- Build a reusable PDO connection function.
- Create the tasks table for browser rendering.
Create the configuration and PDO connection
The htdocs folder is the development server's document root. Placing config.php beside that folder keeps the file outside the browser-facing area.
Database credentials need careful handling. Your password stays in the local configuration file and never appears in a screenshot.
- Switch back to Sublime Text from earlier.
- Create config.php inside pdo-task-board by pasting the code below:
<?php
declare(strict_types=1);
return [
'host' => 'localhost',
'port' => '5432',
'dbname' => 'your_database_name',
'user' => 'your_database_user',
'password' => 'your_database_password',
];
What does this configuration do?
- The returned array keeps the connection settings together.
- The host and port values tell PDO where PostgreSQL is listening.
- The dbname value selects the database that receives the tasks table.
- The user and password values authenticate your existing database login.
- Replace your_database_name with your PostgreSQL database name.
- Replace your_database_user with your PostgreSQL login name.
- Replace your_database_password with your PostgreSQL password.
- Replace localhost if your PostgreSQL login uses another host.
- Replace 5432 if your PostgreSQL login uses another port.
- Save config.php.
- Confirm config.php appears beside htdocs in the project sidebar.
Is config.php inside htdocs?
Move config.php up one folder so it sits directly inside pdo-task-board. The file should be a sibling of htdocs.
Need help checking the file location?
The reference below keeps credential placeholders so your real password stays private.
✔️ Awesome, I've got everything!
Your saved config.php contains your real local database settings.
ⓧ I'd like to double check the full code
<?php
declare(strict_types=1);
return [
'host' => 'localhost',
'port' => '5432',
'dbname' => 'your_database_name',
'user' => 'your_database_user',
'password' => 'your_database_password',
];
A PostgreSQL DSN describes the database endpoint. The connection function builds that DSN once so every script can reuse the same PDO setup.
- Create database.php inside pdo-task-board by pasting the code below:
<?php
declare(strict_types=1);
function database(): PDO
{
$config = require __DIR__ . '/config.php';
$dsn = 'pgsql:host=' . $config['host']
. ';port=' . $config['port']
. ';dbname=' . $config['dbname'];
$pdo = new PDO($dsn, $config['user'], $config['password']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
return $pdo;
}
What does this connection do?
- The database() function loads the settings from config.php.
- The pgsql: prefix selects the PostgreSQL PDO driver.
- The PDO constructor opens the connection with your login.
- The PDO::ERRMODE_EXCEPTION setting turns connection and query failures into exceptions that the app can catch.
Why PDO here?
PDO gives the project one reusable connection pattern. It also keeps the connection object and prepared-statement cycle visible while you learn how application data reaches a database.
- Save database.php.
- Use the running terminal application's new-window control to open a second terminal window.
- Move the second terminal to pdo-task-board by running this command:
cd ~/Desktop/pdo-task-board
What does this command do?
This changes the second terminal's current folder to pdo-task-board. The original terminal keeps serving htdocs.
- Check the syntax in database.php by running this command:
php -l database.php
What does this check do?
The -l option parses the file without running its connection function. This catches PHP syntax mistakes before the database test.
You should see a syntax-check result confirming that database.php passed.
Seeing a syntax problem?
Compare the quotes around pgsql:host= with the reference. Check that every array key has matching square brackets.
Ask for help with the reported line.
✔️ Awesome, I've got everything!
Your reusable PDO connection function is saved and passes the PHP syntax check.
ⓧ I'd like to double check the full code
<?php
declare(strict_types=1);
function database(): PDO
{
$config = require __DIR__ . '/config.php';
$dsn = 'pgsql:host=' . $config['host']
. ';port=' . $config['port']
. ';dbname=' . $config['dbname'];
$pdo = new PDO($dsn, $config['user'], $config['password']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
return $pdo;
}
Initialize the tasks table
The connection function opens PostgreSQL. An initializer uses that connection to create the storage structure that future form submissions need.
The initializer is safe to rerun because the table creation includes IF NOT EXISTS. Existing rows remain untouched when the table already exists.
- Create initialize.php inside pdo-task-board by pasting the code below:
<?php
declare(strict_types=1);
require __DIR__ . '/database.php';
try {
$pdo = database();
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS tasks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
)
SQL);
echo "Database connected. The tasks table is ready.\n";
} catch (PDOException $exception) {
echo "Setup failed: " . $exception->getMessage() . "\n";
exit(1);
}
What does the initializer create?
- The id column gives each task a database-generated identity.
- The title column stores the required task text.
- The created_at column records the insertion time automatically.
- The catch block prints the exception message when setup fails.
- Save initialize.php.
Before you run the initializer, do you expect the connection or table creation to reveal the first problem?
- Create the tasks table by running this command in the second terminal:
php initialize.php
What does this command prove?
This runs the initializer from pdo-task-board. A successful result proves that PDO can authenticate and create the table.
You should see Database connected. The tasks table is ready. in the terminal. That is the storage layer created successfully.
Did the database setup fail?
Check the host and port in config.php against your PostgreSQL login. Check the database name and user next.
If authentication is mentioned, re-enter the local password in config.php without sharing it.
Help me diagnose the initializer failure.
✔️ Awesome, I've got everything!
Your PostgreSQL connection works and the tasks table is ready.
ⓧ I'd like to double check the full code
<?php
declare(strict_types=1);
require __DIR__ . '/database.php';
try {
$pdo = database();
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS tasks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
)
SQL);
echo "Database connected. The tasks table is ready.\n";
} catch (PDOException $exception) {
echo "Setup failed: " . $exception->getMessage() . "\n";
exit(1);
}
Load the table in the browser
The browser app can now replace its request-local array with rows fetched from PostgreSQL. A prepared SELECT reads the newest task IDs first.
The shared h() helper escapes database values before they enter HTML. SQL parameters and HTML escaping protect different boundaries in the request lifecycle.
- In htdocs/index.php from earlier, replace the opening PHP block from <?php through the first ?> with the code below:
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/database.php';
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$tasks = [];
$error = '';
try {
$pdo = database();
$statement = $pdo->prepare(
'SELECT id, title, created_at FROM tasks ORDER BY id DESC'
);
$statement->execute();
$tasks = $statement->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $exception) {
$error = 'Database error: ' . $exception->getMessage();
}
?>
How does the page load rows?
- The require line loads the reusable connection function from outside htdocs.
- The prepared SELECT asks PostgreSQL for each task's ID and title.
- The fetchAll(PDO::FETCH_ASSOC) call returns rows keyed by their column names.
- The exception handler stores a readable local-development error for the page.
- Save htdocs/index.php.
- Return to Firefox ESR from earlier.
- Refresh http://localhost:8000/.
You should still see the task-board heading and form without a PHP syntax error. The new database-loading block now runs on every request.
Did the page stop loading?
Check that dirname(__DIR__) has two closing parentheses. Confirm the try and catch blocks each have matching braces.
Help me fix the database-loading PHP block.
- In htdocs/index.php, replace everything from <body> through </html> with the code below:
<body>
<h1>PDO Task Board</h1>
<p>Tasks submitted here are stored in PostgreSQL.</p>
<?php if ($error !== ''): ?>
<div class="error"><?= h($error) ?></div>
<?php endif; ?>
<form method="post">
<label for="title">New task</label><br>
<input id="title" name="title" required>
<button type="submit">Add task</button>
</form>
<h2>Saved tasks</h2>
<?php if ($tasks === []): ?>
<div class="empty">No tasks found.</div>
<?php else: ?>
<?php foreach ($tasks as $task): ?>
<article class="task">
<strong>#<?= h((string) $task['id']) ?> <?= h((string) $task['title']) ?></strong><br>
<small>Created <?= h((string) $task['created_at']) ?></small>
</article>
<?php endforeach; ?>
<?php endif; ?>
</body>
</html>
How does the database result reach HTML?
- The error block displays a caught database exception through h().
- The empty-state branch handles a table with no rows.
- The loop renders each database-generated ID and task title through h().
- The timestamp also passes through h() before the browser displays it.
- Save htdocs/index.php.
Before you refresh, what do you expect an empty PostgreSQL table to contribute to the page?
- Refresh http://localhost:8000/ in Firefox ESR.
You should see No tasks found. below the form without a database error. That empty state proves the page connected and fetched the table successfully.
Seeing a database error?
Rerun php initialize.php from pdo-task-board to confirm the table still initializes. Recheck the saved values in config.php if the connection fails.
If the old request-local output remains, confirm that both the opening PHP block and the body were replaced in htdocs/index.php.
Help me diagnose the browser result.
✔️ Awesome, I've got everything!
Your browser now loads the PostgreSQL table through PDO and shows its empty state.
ⓧ I'd like to double check the full code
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/database.php';
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$tasks = [];
$error = '';
try {
$pdo = database();
$statement = $pdo->prepare(
'SELECT id, title, created_at FROM tasks ORDER BY id DESC'
);
$statement->execute();
$tasks = $statement->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $exception) {
$error = 'Database error: ' . $exception->getMessage();
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PDO Task Board</title>
</head>
<body>
<h1>PDO Task Board</h1>
<p>Tasks submitted here are stored in PostgreSQL.</p>
<?php if ($error !== ''): ?>
<div class="error"><?= h($error) ?></div>
<?php endif; ?>
<form method="post">
<label for="title">New task</label><br>
<input id="title" name="title" required>
<button type="submit">Add task</button>
</form>
<h2>Saved tasks</h2>
<?php if ($tasks === []): ?>
<div class="empty">No tasks found.</div>
<?php else: ?>
<?php foreach ($tasks as $task): ?>
<article class="task">
<strong>#<?= h((string) $task['id']) ?> <?= h((string) $task['title']) ?></strong><br>
<small>Created <?= h((string) $task['created_at']) ?></small>
</article>
<?php endforeach; ?>
<?php endif; ?>
</body>
</html>
That is the storage foundation in place: your app can connect to PostgreSQL and read the persistent tasks table. Next, you will send form submissions through a prepared insert so tasks remain after a refresh.
Save Tasks with Prepared Statements
Your task board can already read rows from PostgreSQL. Submitting a new title still needs a path from the form into the tasks table.
A PDO prepared statement keeps the submitted title separate from the SQL text. After the insert, a redirect starts a fresh GET request that reads the committed row.
In this step, get ready to:
- Validate each submitted task title before sending it to the database.
- Save the title with a prepared INSERT statement and a named parameter.
- Confirm the redirected page reloads the same task after a refresh.
Save submitted titles safely
PHP exposes the submitted title through $_POST['title']. The app trims surrounding whitespace before deciding whether the title is usable.
- Switch back to the htdocs/index.php tab in Sublime Text.
- Find the call to database() inside the existing try block.
- Add POST handling immediately below that call by pasting this block:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim((string) ($_POST['title'] ?? ''));
if ($title === '') {
$error = 'Enter a task title.';
} else {
$statement = $pdo->prepare(
'INSERT INTO tasks (title) VALUES (:title)'
);
$statement->execute(['title' => $title]);
header('Location: /');
exit;
}
}
What does this code do?
- The request method check limits this branch to form submissions.
- The empty-title check stores a useful message in $error.
- The :title placeholder keeps the submitted value separate from the INSERT statement.
- The values array binds $title to the named placeholder when the statement executes.
- The redirect sends the browser back to the home page after a successful insert.
- The exit statement prevents the original POST request from continuing into the page output.
- Save htdocs/index.php.
- Return to the task board in Firefox ESR.
- Enter Learn prepared statements in the New task field.
- Click Add task.
Firefox ESR returns to http://localhost:8000/. You should see the submitted task with a database-generated ID and creation time.
Good progress. The form now writes a real row into the tasks table.
Task not appearing?
- Confirm that htdocs/index.php is saved in Sublime Text.
- Check that the new POST branch sits inside the existing try block.
- Review the PHP server terminal for a database connection or query failure.
Help me debug why my prepared INSERT is not saving the submitted task.
✔️ Awesome, I've got everything!
Your POST branch now validates the title, inserts a row, and redirects the browser to a fresh request.
ⓧ I'd like to double check the full code
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/database.php';
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$tasks = [];
$error = '';
try {
$pdo = database();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim((string) ($_POST['title'] ?? ''));
if ($title === '') {
$error = 'Enter a task title.';
} else {
$statement = $pdo->prepare(
'INSERT INTO tasks (title) VALUES (:title)'
);
$statement->execute(['title' => $title]);
header('Location: /');
exit;
}
}
$statement = $pdo->prepare(
'SELECT id, title, created_at FROM tasks ORDER BY id DESC'
);
$statement->execute();
$tasks = $statement->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $exception) {
$error = 'Database error: ' . $exception->getMessage();
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PDO Task Board</title>
<style>
body {
max-width: 760px;
margin: 40px auto;
padding: 0 20px;
font-family: sans-serif;
background: #f5f7fb;
color: #1f2937;
}
form, .task, .empty, .error {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: white;
}
input, button {
padding: 10px;
font: inherit;
}
input {
width: 65%;
}
button {
cursor: pointer;
}
.error {
color: #991b1b;
background: #fee2e2;
}
small {
color: #6b7280;
}
</style>
</head>
<body>
<h1>PDO Task Board</h1>
<p>Tasks submitted here are stored in PostgreSQL.</p>
<?php if ($error !== ''): ?>
<div class="error"><?= h($error) ?></div>
<?php endif; ?>
<form method="post">
<label for="title">New task</label><br>
<input id="title" name="title" required>
<button type="submit">Add task</button>
</form>
<h2>Saved tasks</h2>
<?php if ($tasks === []): ?>
<div class="empty">No tasks found.</div>
<?php else: ?>
<?php foreach ($tasks as $task): ?>
<article class="task">
<strong>#<?= h((string) $task['id']) ?> <?= h((string) $task['title']) ?></strong><br>
<small>Created <?= h((string) $task['created_at']) ?></small>
</article>
<?php endforeach; ?>
<?php endif; ?>
</body>
</html>
How is the file organised?
The POST branch saves valid submissions before the existing SELECT reads the task rows. The HTML section escapes every displayed database value through h().
Prove tasks survive refreshes
A page reload starts another request and rebuilds $tasks from the ordered SELECT. The existing h() helper escapes every displayed database value.
Before you refresh, do you think the database-generated ID will stay the same?
- Reload http://localhost:8000/ in Firefox ESR.
You should still see Learn prepared statements with the same database-generated ID. Its creation time should also stay unchanged.
That is the persistence gap closed. PostgreSQL now holds the task between separate browser requests.
Your task board can now save submitted titles safely and reload them on demand. Next, you will filter those persistent rows without placing search input inside the SQL text.
Search Tasks Safely
Your task board already saves every submitted title in PostgreSQL. As the list grows, finding one task becomes harder.
Search adds another browser-controlled value to the request. A prepared statement keeps that value separate from SQL. PostgreSQL performs the filtering with a bound parameter.
In this step, get ready to:
- Capture search text from a GET form.
- Filter stored tasks with a prepared case-insensitive query.
- Prove that clearing the search restores the full task list.
Capture the search request
A GET form places its field value in the page URL. PHP can read that value from $_GET on every request.
- Return to htdocs/index.php in Sublime Text.
- Find the declarations for $tasks and $error near the top of the file.
- Replace those two declarations with this initialization group:
$tasks = [];
$error = '';
$search = trim((string) ($_GET['search'] ?? ''));
What does this code do?
- $search holds the search text from the current URL.
- The empty string provides a default when the URL has no search value.
- trim() removes extra space from the beginning or end of the submitted value.
- Save htdocs/index.php.
- Return to Firefox ESR.
- Refresh http://localhost:8000/.
You'll see the existing task board reload with its saved rows. This confirms the new request value initializes without interrupting the page.
Seeing a page error after the refresh?
Check that $search sits directly below $error = ''; inside the opening PHP block.
Make sure the closing parentheses match the snippet. A missing parenthesis stops PHP before it renders the page.
Help me check the PHP search initialization.
The request value now exists inside PHP. The page still needs a visible form that sends it.
- Find the POST form whose button contains Add task.
- Add this GET form immediately below its closing </form> tag:
<form method="get">
<label for="search">Search tasks</label><br>
<input id="search" name="search" value="<?= h($search) ?>">
<button type="submit">Search</button>
<a href="/">Clear</a>
</form>
How does the search form work?
- method="get" sends the search value through the page URL.
- name="search" provides the key that $_GET['search'] reads.
- h($search) escapes the current value before placing it back into HTML.
- The Clear link returns to the page without a search value.
- Save htdocs/index.php.
- Refresh the task board in Firefox ESR.
- Enter Learn in the Search tasks field.
- Click Search.
You'll see Learn remain in the search field. The browser address now includes the submitted search value.
Search field missing or empty?
Check that the GET form sits between the task-entry form and <h2>Saved tasks</h2>.
Confirm that the input uses name="search". The same key must appear in $_GET['search'].
Help me debug the GET search form.
Filter with a bound search value
The current SELECT always loads every row. The search branch needs a named parameter so browser input remains outside the query text.
- Return to htdocs/index.php in Sublime Text.
- Find the prepared SELECT that loads tasks in descending ID order.
- Replace that prepared SELECT and its execute call with this conditional block:
if ($search === '') {
$statement = $pdo->prepare(
'SELECT id, title, created_at FROM tasks ORDER BY id DESC'
);
$statement->execute();
} else {
$statement = $pdo->prepare(
'SELECT id, title, created_at
FROM tasks
WHERE title ILIKE :search
ORDER BY id DESC'
);
$statement->execute(['search' => '%' . $search . '%']);
}
What does the prepared search do?
- The empty-search branch uses the existing ordered query to load every task.
- ILIKE performs a case-insensitive PostgreSQL title comparison.
- :search marks the complete data value that PDO binds during execution.
- The % characters allow matching text before or after the submitted search value.
- Save htdocs/index.php.
- Return to Firefox ESR.
- Refresh the current task board URL.
You'll see only saved titles containing Learn. The match works regardless of capitalization.
Search showing every task?
Check that the prepared search query sits inside the else branch. Confirm that the original query remains inside the empty-search branch.
Make sure :search matches the search key passed to execute().
Help me debug the prepared search query.
Use this comparison to confirm the complete file before testing both search outcomes.
✔️ Awesome, I've got everything!
Your final htdocs/index.php now includes the GET form and prepared search branch.
ⓧ I'd like to double check the full code
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/database.php';
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$tasks = [];
$error = '';
$search = trim((string) ($_GET['search'] ?? ''));
try {
$pdo = database();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim((string) ($_POST['title'] ?? ''));
if ($title === '') {
$error = 'Enter a task title.';
} else {
$statement = $pdo->prepare(
'INSERT INTO tasks (title) VALUES (:title)'
);
$statement->execute(['title' => $title]);
header('Location: /');
exit;
}
}
if ($search === '') {
$statement = $pdo->prepare(
'SELECT id, title, created_at FROM tasks ORDER BY id DESC'
);
$statement->execute();
} else {
$statement = $pdo->prepare(
'SELECT id, title, created_at
FROM tasks
WHERE title ILIKE :search
ORDER BY id DESC'
);
$statement->execute(['search' => '%' . $search . '%']);
}
$tasks = $statement->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $exception) {
$error = 'Database error: ' . $exception->getMessage();
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PDO Task Board</title>
<style>
body {
max-width: 760px;
margin: 40px auto;
padding: 0 20px;
font-family: sans-serif;
background: #f5f7fb;
color: #1f2937;
}
form, .task, .empty, .error {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: white;
}
input, button {
padding: 10px;
font: inherit;
}
input {
width: 65%;
}
button {
cursor: pointer;
}
.error {
color: #991b1b;
background: #fee2e2;
}
small {
color: #6b7280;
}
</style>
</head>
<body>
<h1>PDO Task Board</h1>
<p>Tasks submitted here are stored in PostgreSQL.</p>
<?php if ($error !== ''): ?>
<div class="error"><?= h($error) ?></div>
<?php endif; ?>
<form method="post">
<label for="title">New task</label><br>
<input id="title" name="title" required>
<button type="submit">Add task</button>
</form>
<form method="get">
<label for="search">Search tasks</label><br>
<input id="search" name="search" value="<?= h($search) ?>">
<button type="submit">Search</button>
<a href="/">Clear</a>
</form>
<h2>Saved tasks</h2>
<?php if ($tasks === []): ?>
<div class="empty">No tasks found.</div>
<?php else: ?>
<?php foreach ($tasks as $task): ?>
<article class="task">
<strong>#<?= h((string) $task['id']) ?> <?= h((string) $task['title']) ?></strong><br>
<small>Created <?= h((string) $task['created_at']) ?></small>
</article>
<?php endforeach; ?>
<?php endif; ?>
</body>
</html>
How to use this reference
Compare the declarations, POST branch, conditional SELECT, and GET form with your saved file. The surrounding task rendering remains unchanged.
Prove the search stays safe
Parameter binding protects the SQL boundary. h() protects the HTML boundary.
- Return to Firefox ESR.
- Click Clear.
- Enter Write PHP forms in the New task field.
- Click Add task.
- Enter Practice PostgreSQL in the New task field.
- Click Add task.
Before you search, predict whether the lowercase value php will match the uppercase PHP in the first task title.
- Enter php in the Search tasks field.
- Click Search.
You'll see Write PHP forms among the matching rows. Practice PostgreSQL is excluded because its title does not contain the search text.
- Click Clear.
You'll see both new tasks in the full list again. The search changed the displayed result set without changing the stored rows.
You have the complete search loop working. Browser input stays separate from SQL while PostgreSQL returns the matching tasks.
Secret mission
Mark Tasks Complete
Turn the task board into a working status tracker. Add a completion column, route each row through a prepared UPDATE, and show completed tasks with a Done label and crossed-out title.
Clean Up Your Resources
Clean Up Your Resources
Everything you built runs locally, so keeping the PHP files and PostgreSQL table costs nothing. You can keep the server running, pause it without losing data, or remove the files and table.
Resources you used:
- The local pdo-task-board folder containing your application files.
- The tasks table containing your saved task rows.
- The PHP development server running at http://localhost:8000/.
Keep everything running
No action needed. Choose this if you want to keep testing the task board or adding features.
- Leave the PHP development server terminal running while you continue working.
- Keep the pdo-task-board folder so your application files remain available.
- Keep the tasks table so your saved tasks and completion statuses remain available.
Pause - I'll come back to this later
Stop only the temporary server process. Your project folder and task rows remain ready for your next session.
- Switch back to the terminal running the PHP development server.
- Press Ctrl+C to stop the server.
- Reload http://localhost:8000/ in your browser.
The task board should no longer load from localhost. This confirms that the server process has stopped.
Delete - I don't want to use this again
Deleting the table and folder is permanent. Your application files and saved task rows cannot be recovered through this cleanup.
- Switch back to the terminal running the PHP development server.
- Press Ctrl+C to stop the server.
- Connect to the database configured in config.php using your PostgreSQL interface.
- Remove the tasks table by executing this query:
DROP TABLE IF EXISTS tasks;
What Does This Query Remove?
This query removes the tasks table from your configured database. Its saved rows and table structure are deleted with it.
Your PostgreSQL interface should report that the query completed. The tasks table is now gone.
- Locate the pdo-task-board folder you created using your file manager.
- Delete the pdo-task-board folder.
- Confirm the folder no longer appears in its previous location.
Nice Work!
Nice Work!
You did it! Your PHP task board now keeps tasks in PostgreSQL through PDO across page refreshes and server restarts.
What you learned:
- Traced a task from an HTML form submission to PHP. Followed the same task through PDO into PostgreSQL. Rendered the stored row as escaped HTML.
- Created a reusable PDO connection with a PostgreSQL DSN. Kept credentials in config.php outside htdocs. Used exception handling to expose local setup failures.
- Used named placeholders in prepared INSERT and search SELECT statements. Kept browser input separate from SQL text. Escaped database values before displaying them.
- Secret Mission: Added an is_complete status to each task. Used a prepared UPDATE action to mark one row complete. Displayed its Done status with completed styling.
Ready to quiz yourself?