PostgreSQL First Contact
Connect to a real Postgres instance, write raw SQL, and understand DALT's database layer
Why Postgres Instead of SQLite
DALT.PHP ships with SQLite by default — zero setup, one file, works everywhere. For learning and local development that's perfect. But SQLite has hard limits:
- One writer at a time (concurrent requests queue up)
- No real types — everything is stored loosely
- Missing features: window functions, full-text search, JSONB, row-level security
- Not what production systems use
PostgreSQL is what you'll run in production. It's the industry-standard open-source relational database — battle-tested, feature-rich, and extremely well-documented.
This lesson teaches you to connect to Postgres, explore it with the psql CLI, write raw SQL, and wire DALT.PHP to it. No ORMs. Direct SQL.
Learning Objectives
By the end of this lesson, you will:
- Connect to a Postgres container using
psql - Know the essential
psqlmeta-commands - Understand Postgres types vs SQLite types
- Write CREATE TABLE, INSERT, SELECT, UPDATE, DELETE in Postgres SQL
- Know why parameterized queries are non-negotiable
- Configure DALT.PHP to use Postgres instead of SQLite
- Run migrations against Postgres with
php artisan migrate
Connecting to Postgres
With your Compose stack running (docker compose up -d), connect to Postgres:
docker compose exec db psql -U postgres -d daltBreaking this down:
docker compose exec db— run a command inside thedbcontainerpsql— the Postgres interactive CLI-U postgres— connect as userpostgres-d dalt— connect to thedaltdatabase
You'll see the psql prompt:
dalt=#You're now inside Postgres. Everything you type here runs as SQL or a psql meta-command.
Essential psql Commands
These meta-commands start with \ and are not SQL — they're psql shortcuts.
\l -- list all databases
\c dalt -- connect to the "dalt" database
\dt -- list all tables in current database
\d users -- describe the users table (columns, types, constraints)
\d+ users -- describe with more detail (indexes, triggers)
\q -- quit psql
\? -- help: list all meta-commands
\timing -- toggle query timing (shows how long each query takes)Try them now:
\dt -- should show the users table (DALT ran migrations on startup)
\d users -- see the column typesPostgres Types vs SQLite Types
SQLite stores everything loosely — the column type is just a hint. Postgres enforces types strictly.
| SQLite | Postgres equivalent | Notes |
|---|---|---|
INTEGER PRIMARY KEY AUTOINCREMENT | BIGSERIAL PRIMARY KEY | BIGSERIAL auto-increments |
TEXT | TEXT | Same |
VARCHAR(255) | VARCHAR(255) | Same |
DATETIME | TIMESTAMPTZ | With timezone — always prefer this |
BOOLEAN | BOOLEAN | True/false, not 0/1 |
REAL | NUMERIC or FLOAT8 | Numeric is exact; float is approximate |
| (none) | JSONB | Binary JSON — Postgres's killer feature |
| (none) | TEXT[] | Arrays of text |
BIGSERIAL PRIMARY KEY — this is the Postgres way to write an auto-incrementing integer primary key. DALT's migration system auto-converts SQLite's INTEGER PRIMARY KEY AUTOINCREMENT to this when DB_DRIVER=pgsql.
TIMESTAMPTZ — always store timestamps with timezone. TIMESTAMP without a timezone stores the raw value and causes bugs when servers change timezone.
Writing Postgres SQL
CREATE TABLE
CREATE TABLE IF NOT EXISTS posts (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
body TEXT,
published BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);Key differences from SQLite:
BIGSERIALnotINTEGER PRIMARY KEY AUTOINCREMENTREFERENCES users(id) ON DELETE CASCADE— foreign key with cascadeTIMESTAMPTZnotDATETIMENOW()notCURRENT_TIMESTAMP(both work,NOW()is idiomatic Postgres)
INSERT
INSERT INTO users (name, email, password)
VALUES ('Alice', 'alice@example.com', 'hashed_password_here');
-- Insert and return the new row
INSERT INTO users (name, email, password)
VALUES ('Bob', 'bob@example.com', 'hashed_password_here')
RETURNING id, name, email;RETURNING — Postgres-specific, very useful. After inserting, get back the generated id without a second query.
SELECT
-- All users
SELECT id, name, email FROM users ORDER BY created_at DESC;
-- One user by id
SELECT * FROM users WHERE id = 1;
-- Search by email (ILIKE = case-insensitive LIKE)
SELECT * FROM users WHERE email ILIKE '%alice%';
-- Count
SELECT COUNT(*) FROM users;UPDATE
UPDATE users
SET name = 'Alice Smith', updated_at = NOW()
WHERE id = 1;
-- Returns affected row count
UPDATE users SET published = true WHERE id = 1;DELETE
DELETE FROM users WHERE id = 1;
-- Safer: soft delete with a column instead
UPDATE users SET deleted_at = NOW() WHERE id = 1;Parameter Safety — The Non-Negotiable Rule
Never concatenate user input into a SQL string. Ever. This is SQL injection — the most common database vulnerability.
What goes wrong
// DANGEROUS — do not do this
$search = $_GET['search']; // attacker sends: ' OR '1'='1
$users = $db->query("SELECT * FROM users WHERE email = '$search'")->get();
// Becomes: SELECT * FROM users WHERE email = '' OR '1'='1'
// Returns every user in the databaseThe correct way — parameterized queries
// SAFE — always do this
$search = $_GET['search'];
$users = $db->query(
'SELECT * FROM users WHERE email ILIKE :search',
['search' => '%' . $search . '%']
)->get();The :search placeholder is never interpreted as SQL. The database treats it as a value, not code. An attacker sending ' OR '1'='1 just searches for that literal string.
DALT's $db->query($sql, $params) method always uses PDO prepared statements. As long as you use :placeholders and pass values in the $params array, you're safe. The bug happens when you break out of this pattern with string concatenation.
Configuring DALT.PHP for Postgres
In your project's .env, change the database settings:
DB_DRIVER=pgsql
DB_HOST=db
DB_PORT=5432
DB_NAME=dalt
DB_USERNAME=postgres
DB_PASSWORD=secretDB_HOST=db — inside Docker Compose, the hostname is the service name (db), not localhost.
If you're running Postgres locally (not Docker), use DB_HOST=127.0.0.1.
Running Migrations Against Postgres
DALT's migration system supports both SQLite and Postgres. Your existing migration files in database/migrations/ work against both.
# If running with Docker Compose
docker compose exec app php artisan migrate
# If running locally (with a local Postgres)
php artisan migrateDALT auto-detects the driver from DB_DRIVER and converts SQLite-only syntax (AUTOINCREMENT, DATETIME) when it encounters a pgsql connection.
To start fresh (drops all tables and re-runs):
docker compose exec app php artisan migrate:freshMaking New Migrations
When you need a new table, create a migration file:
php artisan make:migration create_posts_tableThis generates a timestamped .sql file in database/migrations/. When DB_DRIVER=pgsql, the template uses Postgres syntax automatically:
-- Migration: create_posts_table
-- Created: 2026-05-10
CREATE TABLE IF NOT EXISTS posts (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);Edit this file to match your actual schema, then run php artisan migrate.
Hands-On: Your First Raw SQL Session
Connect to your Compose Postgres container and run these queries manually. This is not a challenge — it's a practice session to build muscle memory.
docker compose exec db psql -U postgres -d daltInside psql:
-- See what tables exist
\dt
-- See the users table structure
\d users
-- Insert a test user
INSERT INTO users (name, email, password)
VALUES ('Test User', 'test@example.com', 'placeholder')
RETURNING id, name, email;
-- List all users
SELECT id, name, email, created_at FROM users ORDER BY created_at DESC;
-- Count users
SELECT COUNT(*) AS total_users FROM users;
-- Update the user
UPDATE users SET name = 'Updated Name' WHERE email = 'test@example.com';
-- Verify the update
SELECT name, email FROM users WHERE email = 'test@example.com';
-- Delete the test user
DELETE FROM users WHERE email = 'test@example.com';
-- Confirm deletion
SELECT COUNT(*) FROM users WHERE email = 'test@example.com';
-- Quit
\qEvery one of these queries works in Postgres. By the time you've run them all once, the syntax stops feeling foreign.
Viewing DALT's Users Table Structure
DALT's migration (database/migrations/001_create_users_table.sql) was written in SQLite syntax, but when DB_DRIVER=pgsql the system auto-converts it. After running migrations on Postgres, check what was created:
docker compose exec db psql -U postgres -d dalt -c "\d users"You should see:
Table "public.users"
Column | Type | Nullable | Default
------------+-----------------------------+----------+--------------------
id | bigint | not null | nextval(...)
name | character varying(255) | not null |
email | character varying(255) | not null |
password | character varying(255) | not null |
created_at | timestamp without time zone | | CURRENT_TIMESTAMP
updated_at | timestamp without time zone | | CURRENT_TIMESTAMPNotice bigint (DALT converted INTEGER PRIMARY KEY AUTOINCREMENT → BIGSERIAL PRIMARY KEY) and timestamp (from DATETIME).
DALT's Database Layer — Quick Reference
$db = \Core\App::resolve(\Core\Database::class);
// SELECT multiple rows
$users = $db->query('SELECT id, name FROM users ORDER BY name')->get();
// SELECT one row (returns false if not found)
$user = $db->query('SELECT * FROM users WHERE id = :id', ['id' => $id])->find();
// SELECT one row or abort 404
$user = $db->query('SELECT * FROM users WHERE id = :id', ['id' => $id])->findOrFail();
// INSERT
$db->query(
'INSERT INTO users (name, email, password) VALUES (:name, :email, :pass)',
['name' => $name, 'email' => $email, 'pass' => $hashed]
);
// UPDATE
$db->query('UPDATE users SET name = :name WHERE id = :id', ['name' => $name, 'id' => $id]);
// DELETE
$db->query('DELETE FROM users WHERE id = :id', ['id' => $id]);
// Raw PDO for transactions
$pdo = $db->getConnection();
$pdo->beginTransaction();
// ... queries ...
$pdo->commit();Your Task
Load the broken controllers:
php artisan challenge:start db-first-queriesTwo controller files will be added to your project. They have three bugs between them — all related to what you learned in this lesson: SQL injection, wrong column names, and invalid response format.
Fix the bugs, then verify:
php artisan challenge:verifyCommon Mistakes
"could not find driver" error
The pdo_pgsql PHP extension is not installed. In Docker, this is fixed by the docker-php-ext-install pdo pdo_pgsql line in your Dockerfile (Phase 1 challenge).
"FATAL: database 'dalt' does not exist"
The Postgres container started fresh without running init. Run docker compose down -v and docker compose up again — the POSTGRES_DB=dalt env var creates the database on first boot.
"column user_id does not exist"
You're querying with the wrong column name. Use \d tablename in psql to see the actual column names.
"echo $array" prints "Array"
PHP arrays can't be echoed directly. Use json_encode($array).
Summary
psqlconnects you to Postgres interactively;\dt,\d tablename,\qare the essential meta-commands- Postgres is strict about types: use
BIGSERIAL,TIMESTAMPTZ,TEXT,BOOLEAN RETURNINGgets back the inserted row without a second query- Always use parameterized queries — never concatenate user input into SQL
- DALT switches to Postgres by changing
DB_DRIVER=pgsqland runningphp artisan migrate $db->query($sql, $params)->get()/->find()/->findOrFail()are your three fetch methods
Next Steps
- Challenge: db-first-queries — fix three SQL bugs in the users controllers
- Lesson 10: PostgreSQL Core — JOINs, aggregations, indexes, transactions