Common Patterns
Recipes for everyday tasks: detail pages, existence checks, single-value queries, search with pagination, lookup maps, and turning result sets into HTML. Each example combines features covered on earlier pages and is ready to adapt.
Record Detail with 404 - or404()
Section titled “Record Detail with 404 - or404()”Load a single record, or send a 404 Not Found response and exit if it
doesn’t exist. When the result has data, or404() returns the row, so it
chains directly onto a selectOne() call:
$id = (int) ($_GET['id'] ?? 0);$user = DB::selectOne('users', ['id' => $id])->or404();// SELECT * FROM `users` WHERE `id` = 123 LIMIT 1
echo "<h1>$user->name</h1>";echo "<p>Member since {$user->createdAt->dateFormat('M j, Y')}</p>";echo "<p>{$user->bio->nl2br()}</p>";or404() takes an optional message: ->or404("That user no longer exists").
For other outcomes on an empty result, orDie($message), orThrow($message),
and orRedirect($url) work the same way.
Missing Row or Empty Column - orThrow()
Section titled “Missing Row or Empty Column - orThrow()”orThrow() works on rows and on values, so one chain can report “no row
matched” and “row found, but the column is empty” as two different errors:
$email = $_POST['email'] ?? '';
$memberId = DB::selectOne('users', ['email' => $email]) ->orThrow("No user found for $email") ->memberId ->orThrow("User $email has no member ID") ->int();The first orThrow() fires when the query returns no row; the second fires
when the row exists but memberId is null or '' (zero counts as present).
When something is missing, the exception says which of the two it was.
Checking a Row Exists - DB::count()
Section titled “Checking a Row Exists - DB::count()”DB::count() returns an int, and 0 is falsy, so the count works directly
in an if statement:
$emailInUse = DB::count('users', ['email' => 'john@example.com']);// SELECT COUNT(*) FROM `users` WHERE `email` = 'john@example.com'
if ($emailInUse) { echo "Email already registered";}One Value from a Query - DB::queryOne()
Section titled “One Value from a Query - DB::queryOne()”Aggregates return one row with one column; queryOne() returns that row
directly, so the value chains right off the call:
$newest = DB::queryOne("SELECT MAX(createdAt) AS newest FROM ::users")->newest;// SELECT MAX(createdAt) AS newest FROM users LIMIT 1
echo "Newest signup: {$newest->dateFormat('M j, Y')}";For columns whose names are awkward to type, read by position with at() -
SHOW CREATE TABLE returns a column literally named Create Table:
$createSql = DB::queryOne("SHOW CREATE TABLE ::users")->at(1)->value();// by name instead: ->{'Create Table'}->value()The same trick works on whole result sets; columnAt() extracts a column by
position when the column name varies:
$tables = DB::query("SHOW TABLES")->columnAt(0)->toArray();// SHOW TABLES names its column after the database ("Tables_in_myapp"), so// extract by position: ['categories', 'orders', 'products', 'users']Insert, Then Load the New Row - DB::insert()
Section titled “Insert, Then Load the New Row - DB::insert()”DB::insert() returns the new auto-increment ID, so inserting and reloading
the full row (with database defaults filled in) is two calls:
$newId = DB::insert('users', [ 'name' => 'Bob Smith', 'email' => 'bob@example.com', 'createdAt' => DB::rawSql('NOW()'),]);// INSERT INTO `users` SET `name` = 'Bob Smith', `email` = 'bob@example.com', `createdAt` = NOW()
$user = DB::selectOne('users', ['id' => $newId]);// SELECT * FROM `users` WHERE `id` = 42 LIMIT 1Search, Sort, and Paginate - DB::likeContains() and DB::pagingSql()
Section titled “Search, Sort, and Paginate - DB::likeContains() and DB::pagingSql()”The standard admin listing: a LIKE search from user input, sorted, paginated, with a total count for the page links:
$search = $_GET['q'] ?? '';$page = (int) ($_GET['page'] ?? 1);$perPage = 25;
$total = DB::count('users', "name LIKE ?", DB::likeContains($search));// SELECT COUNT(*) FROM `users` WHERE name LIKE '%john%'
$totalPages = max(1, ceil($total / $perPage));
$users = DB::select('users', "name LIKE :search ORDER BY name :paging", [ ':search' => DB::likeContains($search), ':paging' => DB::pagingSql($page, $perPage),]);// SELECT * FROM `users` WHERE name LIKE '%john%' ORDER BY name LIMIT 25 OFFSET 0
foreach ($users as $user) { echo "<div>$user->name - $user->city</div>";}
echo "$total results, page $page of $totalPages";Both helpers sanitize their own inputs, so $_GET values can be passed
straight in: likeContains() escapes quotes and the LIKE wildcards % and
_, so a search for 50% matches the literal text 50%; pagingSql()
casts the page number to int and falls back to page 1 on anything empty or
non-numeric.
When $search is empty, likeContains('') generates '%%', which matches
every row (except rows where the column is NULL), so the same query works
with or without a search term.
HTML Table from Query Results
Section titled “HTML Table from Query Results”Loop the result set into rows; values HTML-encode themselves on output:
$users = DB::select('users', ['status' => 'active']);// SELECT * FROM `users` WHERE `status` = 'active'?><table> <thead> <tr><th>Name</th><th>City</th><th>Joined</th></tr> </thead> <tbody> <?php foreach ($users as $user): ?> <tr> <td><?= $user->name ?></td> <td><?= $user->city ?></td> <td><?= $user->createdAt->dateFormat('M j, Y') ?></td> </tr> <?php endforeach ?>
<?php if ($users->isEmpty()): ?> <tr><td colspan="3">No records found</td></tr> <?php endif ?> </tbody></table>An HTML Table from Any Query - keys()
Section titled “An HTML Table from Any Query - keys()”When the columns aren’t fixed (admin tools, debug pages, ad-hoc SQL), build the cells from the data itself. Header names and cell values both HTML-encode themselves on output:
$rows = DB::query("SHOW TABLE STATUS");?><table> <thead> <tr> <?php foreach ($rows->first()->keys() as $name): ?> <th><?= $name ?></th> <?php endforeach ?> </tr> </thead> <tbody> <?php foreach ($rows as $row): ?> <tr> <?php foreach ($row as $value): ?> <td><?= $value ?></td> <?php endforeach ?> </tr> <?php endforeach ?> </tbody></table>Header cells come from the first row’s column names via keys(); body cells
come from each row’s values. No column is named anywhere, so the same
template renders any query.
Select Dropdown from Query Results
Section titled “Select Dropdown from Query Results”One <option> per row, with the id as the value:
$categories = DB::select('categories', "ORDER BY name");?><select name="categoryId"> <option value="">-- Select --</option> <?php foreach ($categories as $category): ?> <option value="<?= $category->id ?>"><?= $category->name ?></option> <?php endforeach ?></select>Grouped Display - groupBy()
Section titled “Grouped Display - groupBy()”groupBy() turns a flat result set into one collection per column value.
Sorting by the group column first keeps each group’s items in order:
use Itools\SmartString\SmartString;
$products = DB::select('products', "ORDER BY category, name");$byCategory = $products->groupBy('category');// ['Books' => [row, row, ...], 'Electronics' => [row, ...]]
foreach ($byCategory as $category => $items) { $category = SmartString::new($category); // foreach keys come back plain; this makes them encode like fields echo "<h2>$category</h2><ul>"; foreach ($items as $item) { echo "<li>$item->name - {$item->price->numberFormat(2)->prepend('$')}</li>"; } echo "</ul>";}One encoding note: group keys are PHP array keys, so $category arrives as a
plain string, not a
SmartString, and doesn’t
HTML-encode itself. Wrapping it in SmartString::new() makes it encode like
any other field.
Lookup Maps - column() and indexBy()
Section titled “Lookup Maps - column() and indexBy()”Both turn a result set into a keyed lookup. Two-argument column() keeps one
column, reading as “the name column, keyed by id”:
$categoryNames = DB::select('categories')->column('name', 'id');// [1 => 'Books', 2 => 'Electronics', 3 => 'Toys', ...]
foreach (DB::select('products') as $product) { echo "<li>$product->name - {$categoryNames->{$product->categoryId->int()}}</li>";}When later code needs more than one column, indexBy() keeps the whole row:
$categoriesById = DB::select('categories')->indexBy('id');echo $categoriesById->{'3'}->name; // the ->{'...'} syntax reads keys plain property syntax can't, like numbersChecking a Column for a Value - contains()
Section titled “Checking a Column for a Value - contains()”With rows already loaded, column() plus contains() answers “is this value
in that column” without another query:
$email = $_POST['email'] ?? '';$admins = DB::select('users', ['isAdmin' => 1]);
if ($admins->column('email')->contains($email)) { echo "That email belongs to an admin";}Metadata queries work too:
$hasEmailIndex = DB::query("SHOW INDEX FROM ::users")->column('Column_name')->contains('email');For a one-off check straight against the database, DB::count() above is
the better tool; contains() earns its keep when the collection is already
in hand, or when several values get tested against the same one.
Default Missing Numbers to Zero - or()
Section titled “Default Missing Numbers to Zero - or()”numberFormat() returns blank when the value is null or not numeric, so a
missing price prints nothing. When a zero should show instead, default the
value first with or(0):
echo $product->price->numberFormat(2); // null price prints nothingecho $product->price->or(0)->numberFormat(2); // null price prints "0.00"Order matters. Before numberFormat(), or() supplies a number to format;
after it, or() supplies display text for values that couldn’t be formatted:
echo $product->price->numberFormat(2)->or('n/a'); // null price prints "n/a"The reverse direction also works: ifZero('') after formatting blanks out
zeros, for report cells where a grid of “0.00” is noise:
echo $row->total->numberFormat(2)->ifZero(''); // "1,234.56", or nothing for 0Calculations in Templates - divide(), subtract(), and percentOf()
Section titled “Calculations in Templates - divide(), subtract(), and percentOf()”The math methods keep the value chainable, so a calculation formats and
falls back like any other value. Nulls, non-numeric values, and division by
zero all produce null, which falls through to the or() or ifNull() at
the end of the chain:
// Per-day averageecho $stats->inquiries->divide($stats->days)->numberFormat(1)->or('-');// 250 inquiries / 30 days → "8.3", zero days → "-"
// Conversion rateecho $stats->leads->percentOf($stats->visitors, 1)->or('-');// 45 of 1,234 → "3.6%"
// Change vs last year, with a plus sign on gainsecho $thisYear->sales ->subtract($lastYear->sales) ->percentOf($lastYear->sales) ->ifNull('-') ->map(fn(string $v) => str_starts_with($v, '-') ? $v : "+$v");// 120 vs 100 → "+20%", 100 vs 120 → "-17%", no last year → "-"The map() on the end runs any callable on the raw value; the result
still HTML-encodes on output.
For rates stored as fractions, percent() multiplies by 100, and its second
argument is a zero fallback:
echo $plan->completionRate->percent(1); // 0.254 → "25.4%"echo $plan->completionRate->percent(0, '-'); // 0 → "-"Address Lines That Skip Empty Fields - append()
Section titled “Address Lines That Skip Empty Fields - append()”append() adds its argument only when the value is present, so separators
after optional fields disappear with the field, no if statements needed:
echo $user->city->append(', ') . $user->region->append(' ') . $user->postalCode;// "Vancouver, BC V6B 1A1" - or "Vancouver, V6B 1A1" when region is emptyprepend() is the same idea in front: $user->balance->prepend('$')
prints $0 for a zero balance and nothing for null (zero counts as present,
null and '' do not).
The appended text becomes part of the value, so it HTML-encodes on output
with everything else. Keep separators plain text; a <br> inside append()
prints as literal text, not a line break.
Values in URLs and JavaScript - urlEncode() and jsonEncode()
Section titled “Values in URLs and JavaScript - urlEncode() and jsonEncode()”The default HTML encoding is wrong for URLs and JavaScript; each context has its own method:
// URL parameterecho "<a href='/users?name={$user->name->urlEncode()}'>$user->name</a>";
// JavaScript variable (jsonEncode() adds the quotes)echo "<script>let userName = {$user->name->jsonEncode()};</script>";Click-to-Call Phone Links - pregReplace()
Section titled “Click-to-Call Phone Links - pregReplace()”A tel: link needs bare digits; the displayed number keeps its formatting.
To get both, pregReplace() runs a regex on the raw value and returns a new
value that HTML-encodes on output like any other:
<a href="tel:<?= $user->phone->pregReplace('/\D/', '') ?>"><?= $user->phone ?></a><!-- <a href="tel:6045551234">(604) 555-1234</a> -->Displaying Trusted HTML - rawHtml()
Section titled “Displaying Trusted HTML - rawHtml()”Values HTML-encode themselves on output, which turns stored HTML into visible
tags. For content that is supposed to contain HTML, like a WYSIWYG editor
field, rawHtml() outputs the value unencoded:
echo $article->title; // HTML-encoded (the default)echo $article->body->rawHtml(); // raw, tags render as HTMLrawHtml() skips encoding entirely, so only use it on content you trust:
HTML written by your own site’s editors, not anything submitted by visitors.