pad

PHP Helpers Reference

PAD pages do their work in the page’s .php file, and the template renders what it leaves behind. The functions on this page are the helpers that PHP code can use - the kind modern PHP frameworks ship (Laravel’s Arr, Str, blank(), session(), Cache::remember(), now(), encrypt() …), written the PAD way: plain functions, a pad prefix, no classes, no Composer. They are loaded on every request, like every file in pad/lib/.

<?php                                         // orders.php

  $orders  = db ( "ARRAY * FROM orders" );
  $byState = padArrGroupBy ( $orders, 'status' );
  $total   = padArrSum ( $orders, 'amount' );
  $title   = padStrHeadline ( padRequest ( 'view', 'all_orders' ) );

?>
Group Functions Manual page
Arrays padArrGet, padArrSet, padArrPluck, padArrGroupBy, … arrays
Strings padStrSlug, padStrLimit, padStrCamel, padStrUuid, … strings
Values padBlank, padFilled, padValue, padRetry, padOnce, … values
Numbers padNumberAbbreviate, padNumberOrdinal, … numbers
Requests and sessions padRequest, padSession, padOld, padUrl, padBack, padAbort, … requests_and_sessions
Environment and cache padEnv, padWasm, padRemember, padCacheGet, padRateLimit, … environment_and_cache
Dates and logging padNow, padAgo, padLog, … dates_and_logging
Hashing and encryption padHash, padEncrypt, padDecrypt, … hashing_and_encryption
Server-sent events padSse, padSseSend, padSseLastId server_sent_events
Authentication and authorization padLogin, padUser, padAttempt, padGate, padCan, padFeature, … -
Migrations and fake data padMigrate, padSeed, padFake*, padFactory, … -
Queues, schedule and HTTP fakes padQueue, padQueueWork, padCronMatch, padCurlFake, … -
Maintenance and assets padMaintenanceDown, padAsset, … -

Arrays

pad/lib/arr.php - reading and writing nested arrays by a dot path, and the everyday work on a list of rows: one field of every row, the rows that pass, grouped, keyed, sorted, summed. Manual page: Array helpers (?pages/arrays).

Function What it answers
padArrGet ( $target, $key, $default = NULL ) The value at a dot path, or the default (a Closure default is called); a NULL key answers the target itself
padArrSet ( &$array, $key, $value ) Sets the value at a dot path, making every missing or plain level an array; answers the array. A * segment sets in every item; a NULL key replaces the whole array
padArrHas ( $array, $keys ) TRUE when every dot path given exists - a NULL value exists. No keys is FALSE
padArrForget ( &$array, $keys ) Removes one or several dot paths; answers the array. A * segment removes from every item (as the last segment: every item)
padArrOnly ( $array, $keys ) The top-level keys given, in the order given; a key not there is left out
padArrExcept ( $array, $keys ) The array without the keys given - dot paths too; the array given (and an object in it) is not changed
padArrPluck ( $rows, $value, $key = NULL ) One field of every row as a list, or keyed by another field; both dot paths (or callbacks)
padArrWhere ( $rows, $key, $operator = NULL, $value = NULL ) The rows that pass, keys kept - see the forms below
padArrFirst ( $array, $callback = NULL, $default = NULL ) The first value, or the first the callback (value, key) is true for, or the default
padArrLast ( $array, $callback = NULL, $default = NULL ) The same from the end
padArrGroupBy ( $rows, $key ) [ group => [ rows ] ] by a dot path or a callback, groups in the order of their first row, each group a list
padArrKeyBy ( $rows, $key ) The rows keyed by a dot path or a callback; a later row wins
padArrSortBy ( $rows, $key, $descending = FALSE ) The rows sorted by a dot path, a callback, or (NULL key) the values; stable; NULL first (last descending); string keys kept, a list numbered again. $descending: TRUE or 'desc'
padArrFlatten ( $array, $depth = INF ) The values as one list, nested arrays opened $depth levels deep (0: none)
padArrDot ( $array, $prepend = '' ) A nested array as one level with dot path keys; an empty array stays a value
padArrUndot ( $array ) Dot path keys made nested arrays again
padArrWrap ( $value ) NULL [], an array itself, anything else [ $value ]
padArrSum ( $rows, $key = NULL ) The sum of the numbers among the values, or a field of every row; 0 for none
padArrAvg ( $rows, $key = NULL ) Their average; NULL for none
padArrMin ( $rows, $key = NULL ) The smallest; NULL for none
padArrMax ( $rows, $key = NULL ) The largest; NULL for none

Dot paths. 'customer.address.city'; a segment may be an integer key ('lines.0.sku'). A key that holds a dot itself - 'a.b' as one key - is found as it is before the path is split. A * segment maps over every item of that level and answers a list ('lines.*.sku'); an item without the rest of the path gives NULL in the list, and a second * makes one list of the lists ('orders.*.lines.*.sku'). Objects are read like arrays: an ArrayAccess object through its offsets, any other object through its public properties.

<?php

  $order = json_decode ( $answer, TRUE );                       // an API's order

  $city  = padArrGet ( $order, 'customer.address.city', 'unknown' );
  $skus  = padArrGet ( $order, 'lines.*.sku' );                   // [ 'TEA', 'MUG' ]
  padArrSet    ( $order, 'customer.address.zip', '2311 AB' );
  padArrForget ( $order, 'customer.password, lines.*.cost' );

  $rows     = db ( "ARRAY * FROM orders" );
  $paid     = padArrWhere   ( $rows, 'status', 'paid' );
  $large    = padArrWhere   ( $rows, 'total', '>=', 100 );
  $someC    = padArrWhere   ( $rows, 'customer', 'like', 'c%' );
  $choices  = padArrPluck   ( $customers, 'name', 'id' );         // id => name, a select box
  $byState  = padArrGroupBy ( $rows, 'status' );
  $newest   = padArrSortBy  ( $rows, 'created', 'desc' );
  $firstBig = padArrFirst   ( $rows, fn ( $row ) => $row ['total'] > 500 );
  $total    = padArrSum     ( $rows, 'total' );
  $public   = padArrExcept  ( $user, 'password, login.ip' );

?>

padArrWhere forms.

Call Keeps the rows where
padArrWhere ( $rows, fn ( $row, $key ) => ... ) the callback answers true
padArrWhere ( $rows, 'status', 'paid' ) the field equals the value (loose ==)
padArrWhere ( $rows, 'total', '>', 100 ) the field compares so with the value
padArrWhere ( $rows, 'active' ) the field is true in PHP’s sense

Operators: = == === != <> !== < > <= >=, PAD’s eq ne lt gt le ge, in and not in with a list of values, like with % (any run) and _ (one character), case-insensitive and unicode-aware, \% and \_ the characters themselves. Case and spaces in the operator do not matter ('NOT IN'). Equality is PHP’s loose ==, so the '5' a database answers equals a 5 written in PHP; === is strict. A NULL field matches no like; an object compared with a plain value is neither equal, smaller nor larger (no PHP warning).

Edge rules.


Strings

pad/lib/str.php - text counted in characters (UTF-8), never in bytes, so an accented letter or an emoji is never cut in half: padStrLimit, padStrMask and padStrExcerpt count grapheme clusters when PHP has intl - a flag, a skin-toned or family emoji, a letter with a combining accent is one character - and code points (mb_*) without it. Manual page: String helpers (strings).

Function Answers
padStrLimit ( $text, $limit = 100, $end = '...' ) At most $limit characters; when something was cut, the trailing space trimmed and $end added ($end is not counted)
padStrWords ( $text, $words = 100, $end = '...' ) The first $words words (runs of non-whitespace), $end added when words were left out
padStrExcerpt ( $text, $phrase, $radius = 100, $omission = '...' ) The first place the phrase stands - found case-insensitively, shown as the text has it - with up to $radius characters on either side, $omission on a side that was cut; '' when the phrase is not there
padStrSquish ( $text ) Trimmed, every run of whitespace (tabs, newlines, no-break and other unicode spaces) one space
padStrCamel ( $text ) userName
padStrStudly ( $text ) UserName
padStrSnake ( $text, $delimiter = '_' ) user_name
padStrKebab ( $text ) user-name
padStrHeadline ( $text ) User Name
padStrTitle ( $text ) Hello World - PHP’s unicode title case of the whole text
padStrSlug ( $text, $separator = '-' ) A readable URL part, 'Crème Brûlée & Co.' -> creme-brulee-co - the slug pipe calls it
padStrAfter ( $text, $search ) What follows the first occurrence - the after pipe calls it
padStrAfterLast ( $text, $search ) What follows the last occurrence - the afterLast pipe
padStrBefore ( $text, $search ) What precedes the first occurrence - the before pipe
padStrBeforeLast ( $text, $search ) What precedes the last occurrence - the beforeLast pipe
padStrBetween ( $text, $from, $to ) What stands between the first $from and the last $to
padStrIs ( $pattern, $value ) TRUE when the value matches the pattern, * standing for any run of characters; $pattern may be a list (any one matching)
padStrMask ( $text, $character, $index, $length = NULL ) The characters from $index on ($length of them, or to the end) replaced by $character
padStrRandom ( $length = 16 ) $length characters from A-Z a-z 0-9, drawn with random_int
padStrUuid ( $version = 4 ) An RFC 9562 UUID, lower case 8-4-4-4-12: version 4 (random) or 7 (time-ordered)
padStrPlural ( $word, $count = 2 ) The English plural of the last word; the word as it is for a count of 1 or -1
padStrSingular ( $word ) The English singular of the last word
<?php                                         // posts.php

  $posts = db ( "ARRAY id, title, body, created_at FROM posts" );

  foreach ( $posts as $i => $post ) {
    $posts [$i] ['teaser'] = padStrWords   ( $post ['body'], 30 );
    $posts [$i] ['slug']   = padStrSlug    ( $post ['title'] );
    $posts [$i] ['hit']    = padStrExcerpt ( $post ['body'], $search ?? '', 60 );
  }

  $heading = count ( $posts ) . ' ' . padStrPlural ( 'post', $posts );    // 1 post, 2 posts

  $label   = padStrHeadline ( 'created_at' );                             // Created At
  $class   = padStrKebab    ( 'OrderTotal' );                             // order-total
  $card    = padStrMask     ( '4111111111111111', '*', 4, -4 );           // 4111********1111
  $token   = padStrRandom   ( 40 );
  $id      = padStrUuid     ( 7 );                                        // 01920b6e-...-7...

  if ( padStrIs ( [ 'admin/*', 'reports/*.pdf' ], $padPage ) )
    $restricted = TRUE;

?>

The case functions read the words of a name whatever case it is in: a run of letters and digits is a word, and anything else separates - an underscore, a dash, a space, a dot - as does a change of case (userName is user + Name, HTMLParser is HTML + Parser, address2Line is address2 + Line). Each word is then written in the case asked for, so the functions turn into each other in every direction, and an acronym reads as a word: XMLHttpRequest -> xml_http_request, XmlHttpRequest, Xml Http Request. An apostrophe inside a word keeps it whole (it's done -> It's Done).

After, before, between are the pipes after, afterLast, before and beforeLast - the pipes call these functions. A text without the search string comes back unchanged; an empty search string is found at the start by After/Before and at the end by AfterLast/BeforeLast (as strpos/strrpos find it): padStrAfter ( 'abc', '' ) is abc, padStrBefore ( 'abc', '' ) is ''. padStrBetween is padStrBeforeLast ( padStrAfter ( $text, $from ), $to ), as Laravel’s Str::between - '[a] and [b]' between [ and ] is a] and [b; the first $to is padStrBefore ( padStrAfter ( $text, $from ), $to ). (The between pipe is something else: a test whether a number lies between two.)

Mask: a negative $index counts from the end; a negative $length stops that many characters before the end, as mb_substr reads it. $character longer than one character gives its first. An index past the end masks nothing.

UUID version 7 starts with the Unix time in milliseconds, so ids made later sort later and a database index on them stays in order; within one millisecond a 12-bit counter (RFC 9562, 6.2 method 1) keeps the ids of one request in the order they were made.

Plural and singular handle the irregular words (person/people, child/children, man/men, woman/women, mouse/mice, goose/geese, foot/feet, tooth/teeth, ox/oxen, criterion, cactus, analysis, index, hero/heroes, knife/knives …), also as the end of a compound (salesperson, grandchild, bookshelf); the uncountable ones (sheep, fish, series, species, information, equipment, news, money, rice, data, software …); and the rules: consonant + y -> ies, s x z ch sh -> es, -sis -> -ses, -us -> -uses. The last word of the text is the one changed (blog post -> blog posts, salesPerson -> salesPeople), and its case is kept: Person -> People, PERSON -> PEOPLE. A word already plural stays plural, a word already singular stays singular. The count may be an int, a float, a numeric string, or an array or Countable (its size); only 1 and -1 keep the word, so 0 items reads right.

Edge values. A text argument may be anything PHP turns into a string: NULL and FALSE are '', a number is its digits, an object with __toString its string - so a database NULL needs no check first. Bytes that are not valid UTF-8 become ? (except in the functions the pipes share, which answer what the pipes always did). Wrong input is reported as a PAD error (padError) naming the function and the fault, after which the function answers its empty value - '', or for padStrPlural the word as it is:

Fault Example
An array or a non-string object as a text padStrSquish ( [ 'a' ] ) - padStrSquish needs a text, not array
A limit, number of words, radius or length that is negative or not a whole number padStrLimit ( $t, -1 ), padStrWords ( $t, 2.5 ), padStrRandom ( -8 )
A mask index or length that is not a whole number padStrMask ( $t, '*', 'first' )
An empty mask character padStrMask ( $t, '', 4 ) - never the text unmasked
A UUID version other than 4 or 7 padStrUuid ( 5 )
A count that is not a number, an array or a Countable padStrPlural ( 'file', 'many' )

A limit of 0 keeps nothing: padStrLimit ( 'abc', 0 ) is ..., padStrWords ( 'a b', 0 ) is ...; a text that fits is never given the ending. A whole number may be written as a string ('3') or a float without a fraction (3.0).


Values

pad/lib/helpers.php - weighing, defaulting and guarding values; manual page Value helpers.

Function Answers
padBlank ( $value ) TRUE for NULL, '' and whitespace-only text (unicode spaces too), an empty array, an empty Countable or Stringable; FALSE for 0, '0', 0.0, FALSE, TRUE and every other value
padFilled ( $value ) ! padBlank ( $value )
padValue ( $value, ...$args ) a Closure called with the arguments; anything else as it is
padTransform ( $value, $callback, $default = NULL ) $callback ( $value ) when the value is filled, else the default - a Closure default called with the blank value
padTap ( $value, $callback ) calls $callback ( $value ), answers the value
padRetry ( $times, $callback, $sleepMilliseconds = 0, $when = NULL ) the callback’s answer, called with the attempt number (1, 2 …) until it does not throw, at most $times times (INF: no limit)
padRescue ( $callback, $rescue = NULL, $report = TRUE ) the callback’s answer, or padValue ( $rescue, $e ) when it throws
padOnce ( $callback ) the callback’s answer, run once per request for the file and line that call padOnce
<?php                                           // checkout.php

  if ( padBlank ( $coupon ) )                   // '0' is a coupon, '  ' is none
    $coupon = 'NONE';

  $title = padTransform ( $customer ['name'], fn ( $n ) => ucwords ( $n ), 'Guest' );

  $rates = padRetry ( 3, fn ( $attempt ) => fetchRates (), [ 100, 500 ] );   // throws when down

  $news  = padRescue ( fn () => newsFeed (), [], FALSE );   // the page goes on without it

  function settings () {
    return padOnce ( fn () => db ( "RECORD * FROM settings" ) );   // one query per request
  }

?>

Numbers

pad/lib/number.php - numbers written for people, and kept between bounds; manual page Number helpers. The pipes abbreviate and ordinal are the template side (FUNCTIONS.md), and the bytes pipe answers what padNumberFileSize answers.

Function Answers
padNumberFormat ( $number, $decimals = 0, $locale = NULL ) the number with exactly $decimals decimals, grouped, the locale’s way - $padLocale unless one is given: 1,234,567.89 in en, 1.234.567,89 in nl
padNumberPercentage ( $number, $precision = 0 ) a share counted in hundreds with exactly $precision decimals, the request’s locale’s way: 25 → 25%, 25.55 with 1 → 25.6% (25,6% in nl)
padNumberAbbreviate ( $number, $precision = 0 ) 1000 → 1K, 1500 → 2K, with precision 1 1.5K; then M, B, T, Q; below 1000 no letter
padNumberForHumans ( $number, $precision = 0 ) 1 thousand, 1.5 million, 2 billion, trillion, quadrillion
padNumberFileSize ( $bytes, $precision = 0 ) units of 1024: 1536 → 2 KB, with precision 1 1.5 KB; B to EB
padNumberOrdinal ( $number ) 1st 2nd 3rd 4th 11th 12th 13th 21st 101st 111th - English
padNumberClamp ( $number, $min, $max ) the number, or the bound it went past - a number
<?php                                           // stats.php

  $revenue   = padNumberFormat ( $sum, 2 );                 // 1,234,567.89
  $growth    = padNumberPercentage ( $rise * 100, 1 );      // 12.5%
  $followers = padNumberAbbreviate ( $count, 1 );           // 48.2K
  $headline  = 'Over ' . padNumberForHumans ( $visits ) . ' visits';   // Over 2 million visits
  $backup    = padNumberFileSize ( filesize ( $file ), 1 ); // 5.1 MB
  $place     = padNumberOrdinal ( $rank );                  // 22nd
  $page      = padNumberClamp ( $page, 1, $pages );         // never past the last page

?>

Requests and sessions

pad/lib/request.php - what a page’s PHP asks of the request it answers, of the session that outlives it, and of the address the visitor goes to next. The engine already turns request values into variables ($padRequestVars) and keeps the $padSessionVars names in the session; these do the same by name, whatever those settings let through. Manual page: Requests and sessions.

Function Answers
padRequest ( $key = NULL, $default = NULL ) One value of this request’s input by name or dot path, or the default (a Closure is called); NULL key: all the input as an array
padRequestHas ( $keys ) TRUE when every name is in the input - an empty value counts
padRequestFilled ( $key ) TRUE when every name is in the input and not blank
padRequestOnly ( $keys ) The input with these names only, in the order given, each where its dot path puts it; a name not sent is left out
padRequestExcept ( $keys ) The input without these names (dot paths remove a nested value)
padRequestMethod () The method upper-cased - GET, POST, PUT, M-SEARCH … any method HTTP allows; GET when there is none (the command line). The engine asks it too - the CSRF check, padPosted, the page cache and the 304
padRequestIs ( $method ) TRUE when the method is this one, or one of a list / comma-separated text, in any case
padSession ( $key = NULL, $default = NULL ) One value of the session by name or dot path, or the default; NULL key: all of it ([] without a session). Never starts a session
padSessionPut ( $key, $value = NULL ) Keeps a value at a dot path, starting the session when there is none; an array of names and values puts them all. TRUE, or FALSE when no session can start (headers sent)
padSessionHas ( $keys ) TRUE when every name is in the session - a NULL value counts
padSessionPull ( $key, $default = NULL ) The value, taken out of the session; the default when it is not there
padSessionForget ( $keys ) Removes one or several names or dot paths from the session
padSessionRegenerate () Moves the session to a new id, its data kept and its CSRF token renewed - after a login, against session fixation. TRUE / FALSE
padFlashInput ( $except = [ 'password', 'password_confirmation', 'padCsrfToken' ] ) Keeps this request’s input, without those names - by default also without any field, at any depth, whose name says it holds a secret (current_password, user[password], api[key], card_number, otp_code …; a word rule, see below) - for the next request only. TRUE / FALSE
padOld ( $key = NULL, $default = '' ) A value of the input the request before flashed (or this one, when it flashed); NULL key: all of it
padUrl ( $page = '', $vars = [], $absolute = FALSE ) A link to a page of this application: /myapp/?orders&sort=date, or /myapp/orders?sort=date with $padCleanUrls; '' is the page the visitor asked for; absolute on $padHost
padBack ( $fallback = '' ) Redirects (302) to the referring page when it is a page of this application on this host, else to $fallback (a page name; '' the index)
padAbort ( $status, $message = '' ) Ends the request with the status (400-599) and the message - or the status’s own phrase - as plain text
<?php                                          // signup.php - the page the form posts to

  $errors = padValidate ( [ 'email' => 'required|email' ] );

  if ( $errors ) {
    padFlash ( 'Please check the form.', 'error' );
    padFlashInput ();                          // password and CSRF token left out
    padBack ( 'signup/form' );                 // to the Referer, if it is ours
  }

  padSessionPut ( 'user.email', padRequest ( 'email' ) );
  padSessionRegenerate ();

?>
<?php                                          // signup/form.php - filled again once

  $email = padOld ( 'email' );                 // {input 'email', value=$email}

?>
  $sort   = padRequest ( 'sort', 'date' );                         // ?orders&sort=total
  $name   = padRequest ( 'customer.name' );                        // customer[name], or JSON
  $ids    = padRequest ( 'items.*.id' );                           // [ '3', '7' ]
  $save   = padRequestOnly ( 'customer.email, note' );
  $cart   = padSession ( 'cart.items', [] );
  $coupon = padSessionPull ( 'cart.coupon' );
  $next   = padUrl ( 'orders', [ 'page' => 2 ] );                  // /myapp/?orders&page=2

  if ( ! $order )
    padAbort ( 404 );                                              // 404 Not Found

The input is POST over GET, and the decoded body of a request sent as application/json (or a +json type) over both, as Laravel merges them; every text is trimmed as inits/parms.php trims a request value; the bare query key that names the page ($padPageKey) is no input, and route segments (products/[id]) are variables, not input. A dot path finds a key of its own first ('a.b' as one key), then walks the levels; a segment may be a number, and a * maps the rest of the path over every item of a list, answering the items that have it. Objects are read by their public properties, ArrayAccess objects like arrays. The names of Has, Filled, Only, Except and Forget are an array or one text with commas.

Edge rules:


Environment and cache

Environment values

Function What it answers
padEnv ( $key, $default = NULL ) The value of an environment key: the real environment (getenv, $_ENV, $_SERVER) first, then the .env in the application’s root (apps/shop/.env), then _common’s .env for an application that runs with _common, then .env in the PAD home, else the default - a Closure default is called
padWasm () TRUE when PAD runs on PHP compiled to WebAssembly, in a browser - an application packed by pad pack (manual page Offline applications). The runtime there passes PAD_WASM in $_SERVER, which no request can fill; a page asks it to leave out what needs a server (pad/lib/wasm.php)

pad/lib/env.php. Machine-specific values and secrets - a database password, an API key, a debug switch - stay out of the code and out of git. The engine loads pad/lib/ before it reads any configuration, so a configuration file can use it:

<?php                                         // _config/config.php

  $padSqlPassword = padEnv ( 'padSqlPassword' );
  $padSqlUser     = padEnv ( 'padSqlUser', 'shop' );
  $padToolbar     = padEnv ( 'APP_DEBUG', FALSE ) ? 'local' : FALSE;

?>

The .env format - the one other tools read too:

# a comment
APP_NAME=Shop                   a bare value, trimmed; a # at its start or after a space starts a comment
export DB_HOST=localhost        export in front, as a shell script writes it
GREETING="Hello\nWorld"         double quotes: \n \r \t \" \\ \$ and ${OTHER}
PATTERN='${not} \n expanded'    single quotes: the text exactly as written
DEBUG=false                     true, false, null, empty - also (true) ..., any case - are TRUE, FALSE, NULL, ''
NOTE="two
lines"                          a quoted value may run over several lines

Edge rules:

Cache and rate limits

Function What it answers
padCacheGet ( $key, $default = NULL ) The value kept under the key, else the default - a Closure default is called
padCachePut ( $key, $value, $ttl = 3600 ) Keeps the value for $ttl seconds (NULL for ever); TRUE when it is kept
padCacheHas ( $key ) Whether the key holds a value that has not expired - a kept NULL counts
padCacheIncrement ( $key, $by = 1, $ttl = 3600 ) Adds $by to the number kept under the key, under the application’s lock - requests at once are each counted; the new number (a key holding no number starts from 0, an entry keeps its end of life)
padCacheForget ( $key ) Removes the key; TRUE when it held a value
padCacheFlush () Removes every entry of this application, its rate limits included; TRUE
padRemember ( $key, $ttl, $callback ) The kept value, or the callback’s answer, kept for $ttl seconds
padRateLimit ( $key, $maxAttempts, $decaySeconds = 60 ) Counts a hit: TRUE within $maxAttempts per window of $decaySeconds, FALSE over it
padRateLimitRemaining ( $key, $maxAttempts ) The hits left in the current window
padRateLimitAvailableIn ( $key ) The seconds until the current window ends, 0 when none runs
padRateLimitClear ( $key ) Ends the window: the key starts from nothing; TRUE

pad/lib/remember.php. A file store under DATA/cache/app/<application>/: each application has its own entries, one file per key named by a hash of the key, holding the expiry time and the serialized value. A file is written beside its place and renamed over it, so a reader sees the old value or the new one, never half of one.

<?php                                         // products.php

  $products = padRemember ( 'products', 600, function () {
    return db ( "ARRAY * FROM products ORDER BY name" );
  } );

?>
<?php                                         // product/save.php

  db ( "UPDATE products SET price={0} WHERE id={1}", [ $price, $id ] );

  padCacheForget ( 'products' );
  padRedirect    ( 'products' );

?>
<?php                                         // login.php

  $key = 'login:' . $_SERVER ['REMOTE_ADDR'];

  if ( padPosted ( 'login' ) ) {

    if ( ! padRateLimit ( $key, 5, 60 ) )
      $error = 'Too many attempts - try again in ' . padRateLimitAvailableIn ( $key ) . ' seconds.';

    elseif ( loginValid ( $email, $password ) ) {       // the application's own check
      padRateLimitClear ( $key );
      padRedirect ( 'account' );
    }

  }

?>

Edge rules:


Dates and logging

pad/lib/date.php and pad/lib/log.php - manual page Dates and logging.

Function Answers
padNow ( $format = NULL ) Now as a DateTimeImmutable in the application’s timezone ($padTimezone, else PHP’s); with a format the formatted string
padToday ( $format = NULL ) Today at midnight, the same way
padNowFreeze ( $time = NULL ) Fixes now for the rest of the request - a test, a replay - and answers the frozen moment; NULL lets the clock run again and answers NULL
padDateParse ( $value ) A DateTimeImmutable from a Unix timestamp, a date in text or a DateTimeInterface; NULL for what is none of those
padAgo ( $date, $now = NULL ) The age in words, English: just now, 30 seconds ago, 1 minute ago, 5 hours ago, yesterday, 3 days ago, 2 weeks ago, 1 month ago, 4 years ago - and in 2 minutes, tomorrow, … for a moment to come; counted from $now when given
padLog ( $message, $level = 'info', $context = [] ) Appends one line to DATA/logs/<application>/<Y-m-d>.log; TRUE when it was written

The pipe ago is padAgo in a template: {$created | ago}, {$start | ago($end)} (FUNCTIONS.md).

<?php                                         // comments.php

  $comments = db ( "ARRAY * FROM comments ORDER BY posted DESC" );
  $edited   = padAgo ( $page ['changed'] );                  // '3 hours ago'
  $deadline = padDateParse ( $order ['due'] ) ?->format ( 'l j F' );
  $year     = padNow ( 'Y' );

  if ( ! $comments )
    padLog ( 'No comments for page {page}', 'notice', [ 'page' => $padPage ] );

?>
<?php                                         // a test, or a look at another day

  padNowFreeze ( '2026-12-24 18:00:00' );

  $now      = padNow ();                      // 2026-12-24 18:00:00, every time
  $tomorrow = padDateParse ( 'tomorrow' );    // 2026-12-25 00:00:00 - from the frozen now
  $age      = padAgo ( '2026-12-20' );        // '4 days ago'

  padNowFreeze ();                            // the clock runs again

?>

A log line - 2026-10-05 14:30:00 INFO Order 1042 paid by Ann {"order":1042,"customer":"Ann"}:

padLog ( 'Order {order} paid by {customer}', 'info', [ 'order' => 1042, 'customer' => 'Ann' ] );
padLog ( 'Import failed', 'error', [ 'exception' => $e, 'file' => $name ] );

Edge rules:


Hashing and encryption

pad/lib/crypt.php - manual page Hashing and encryption.

Function Answers
padHash ( $password ) The password hashed with password_hash and PASSWORD_DEFAULT - a new salt each time - the text to store
padHashCheck ( $password, $hash ) Whether the password matches the hash (password_verify)
padHashNeedsRehash ( $hash ) Whether the hash was made with weaker settings than PHP’s default now - make it again at the next login
padEncrypt ( $value ) The value JSON-encoded and sealed with sodium_crypto_secretbox and a random nonce, as one URL-safe string
padDecrypt ( $payload, $default = NULL ) The value back; the default for a payload that was changed, cut short, is not ours or was sealed with another key
padSignedUrl ( $page, $vars = [], $expires = NULL ) An absolute link to a page of this application with padExpires (when it expires) and an HMAC-SHA256 padSignature
padSignatureValid () Whether this request carries a valid, unexpired signature
<?php                                         // login.php

  $user = db ( "RECORD * FROM users WHERE email = {0}", [ $email ] );

  if ( ! padHashCheck ( $password, $user ['hash'] ?? NULL ) )
    $error = 'Unknown e-mail address or wrong password';
  elseif ( padHashNeedsRehash ( $user ['hash'] ) )
    db ( "UPDATE users SET hash = {0} WHERE id = {1}", [ padHash ( $password ), $user ['id'] ] );

?>
<?php                                         // a value the browser carries but cannot read

  setcookie ( 'cart', padEncrypt ( [ 'items' => [ 12, 40 ], 'coupon' => 'WELCOME' ] ) );

  $cart = padDecrypt ( $_COOKIE ['cart'] ?? '', [ 'items' => [] ] );

?>
<?php                                         // mail a link that works for two days

  $link = padSignedUrl ( 'invoice', [ 'id' => $invoice ['id'] ], '+2 days' );
  // https://example.com/shop/?invoice&id=1042&padExpires=1791311400&padSignature=4ade22...

?>
<?php                                         // invoice.php - the page the link leads to

  if ( ! padSignatureValid () )
    padRedirect ( 'link_expired' );

?>

The key. Sealing and signing use the application key, each through a key of its own derived from it. $padAppKey (in _config/config.php, see pad/config/config.php) is the key when set: 32 bytes, or 'base64:' followed by 32 bytes in base64. Empty - the default - it is the file DATA/keys/<application>.key, made on first use from random_bytes with mode 0600 (its directory 0700) and holding the key in the 'base64:...' form, ready to copy into $padAppKey - the setting for several servers that must read each other’s values. DATA/ is outside the web root, so no URL reaches the file. A new key makes every value sealed and every link signed before it unreadable. The error reports and dumps redact $padAppKey, and the key itself is never kept in a global.

Edge rules:


Server-sent events

pad/lib/sse.php - manual page Server-sent events.

Function Answers
padSse ( $producer, $options = [] ) Streams the events the producer sends as text/event-stream and ends the request - the page’s PHP is the whole answer
padSseSend ( $event, $data = '', $id = NULL ) Writes one event past PAD’s output buffers - an array as JSON, text over several data: lines - and answers whether the visitor is still connected
padSseLastId () The id of the last event the browser had, sent back on a reconnect (Last-Event-ID, or lastEventId in the query)
<?php                                         // ticker.php - the producer loops itself

  padSse ( function ( $send, $lastId ) {
    for ( $n = (int) $lastId + 1; $n <= 10; $n++ ) {
      if ( ! $send ( 'tick', [ 'n' => $n ], $n ) )
        break;
      sleep ( 1 );
    }
  }, [ 'retry' => 3000 ] );

?>
<?php                                         // stats.php - PAD calls the producer every 2 s

  padSse ( fn () => [ 'orders' => db ( "FIELD count(*) FROM orders" ) ],
           [ 'every' => 2, 'for' => 60, 'event' => 'stats' ] );

?>

Authentication and authorization

pad/lib/auth.php, pad/lib/gate.php, pad/lib/feature.php.

Function Answers
padLogin ( $user, $remember = FALSE ) Keeps the row (an id at least) as this application’s logged-in user, without its password, secret and token fields, under a new session id; $remember TRUE (30 days) or seconds sets a signed remember-me cookie - $padAuthRemember names the function that finds the row by id
padLogout () Forgets this application’s user and its $padSessionVars, drops the cookie, new session id - other applications stay logged in
padUser ( $field = NULL ) The row, or one field of it (dot paths); NULL for a guest
padUserId (), padAuthCheck () The id; whether a user is logged in
padAttempt ( $password, $hash, $user, $remember = FALSE, &$rehash = NULL ) Checks the password and logs in; without a user it still checks against a dummy hash, so timing tells nothing; $rehash gets a new hash when the stored one is outdated
padAuthRequire ( $login = 'login' ) A guest is sent to the login page, the address asked for kept (on a GET)
padRedirectIntended ( $default = 'index' ) After the login: back to the kept address - never off the site - else the default
padPasswordToken ( $email, $passwordHash, $ttl = 3600 ) A reset token: an expiry and an HMAC over the application, the e-mail, the expiry and the current hash - no table
padPasswordTokenCheck ( $token, $email, $passwordHash ) Whether it holds: not expired, the same address, the password not changed since
padGate ( $ability, $callback ) Defines an ability (in _lib, every request): the callback gets the user and the values
padGateBefore ( $callback ) A hook asked first: TRUE or FALSE decides, NULL passes on
padCan ( $ability, ...$values ), padCannot ( ... ) Whether the user may
padAuthorize ( $ability, ...$values ) padAbort ( 403 ) when not
padFeature ( $name ) Whether the flag of $padFeatures is on for this visitor
padFeatureOverride ( $name, $on ) Fixes a flag for the rest of the request - NULL undoes it
padFeatureId ( $id = NULL ) The stable id shares are decided by - user:<id> or the padFeatureId cookie; given, it is used for the rest of the request

The function $padAuthRemember names gets the id a remember-me cookie holds before its signature is checked - the signature holds the user’s password hash, so the row must be found first. It is a visitor’s value: an integer, or a plain token of letters, digits and _ . @ + : -, at most 128 - any other is never handed over. Look it up with a placeholder, never pasted into SQL: db ( "RECORD * FROM users WHERE id={0}", [ $id ] ).

Migrations and fake data

pad/lib/migrate.php, pad/lib/fake.php - the pad migrate and pad seed commands.

Function Answers
padMigrate ( $pretend = FALSE ) Runs the pending migrations of _migrations/ as one batch - the names that ran; pretending, the statements per migration
padMigrateStatus () Every migration: migration, ran, batch
padMigrateRollback ( $steps = 1, $pretend = FALSE ) The downs of the last batches, newest first
padMigrateFresh ( TRUE ) Every table and view dropped, then every migration - refused without TRUE
padMigrateError () The last failure’s message, or ''
padSeed ( $name = '' ) The seeders of _seeds/ in name order, or the one named (users for 01_users.php)
padFakeSeed ( $seed ) Starts the fake sequence - the same values again for the same seed; mt_rand untouched; the values padFakeUnique gave stay given
padFakeNumber, padFakeFloat, padFakeBool, padFakePick, padFakePicks Numbers, chances and picks
padFakeFirstName, padFakeLastName, padFakeName, padFakeEmail, padFakePhone, padFakeCompany, padFakeCity, padFakeCountry, padFakeStreet People and places - e-mail on example.com/org/net, phones in the 555-01xx range
padFakeWord, padFakeWords, padFakeSentence, padFakeParagraph Text
padFakeDate ( $from, $to, $format ), padFakeUuid () A moment between two (relative ones follow padNowFreeze); a v4 UUID
padFakeUnique ( $key, $function, ...$args ), padFakeUniqueReset ( $key ) A value never given twice under the key in the request, a new seed or not; the reset forgets them - after padFakeSeed the same values again
padFactory ( $table, $count, $row ) Inserts $count rows - $row an array or fn ( $i ) - with placeholders; the rows with their ids

Queues, schedule and HTTP fakes

pad/lib/queue.php, pad/lib/schedule.php, pad/lib/curlFake.php - the pad work, pad queue and pad schedule commands.

Function Answers
padQueue ( $job, $data = [], delay:, queue:, tries:, backoff: ) Stores a job for _jobs/<job>.php - its id
padQueueSize ( $queue ), padQueueQueues () Jobs waiting; every queue with its due, delayed and running counts
padQueueWork ( $queue, $max = 0, $timeout = 60, $tries = NULL ) Runs the due jobs: [ done, retried, failed, released, jobs ]
padQueueFailed (), padQueueRetry ( $id \| 'all' ), padQueueFlush ( 'failed' \| $queue ) The failed jobs; back on their queue; removed
padQueueRun ( $job, $data ) Runs a handler once, now
padCronMatch ( $expr, $time ), padCronNext ( $expr, $time ) Whether a cron expression matches a minute; the next minute it does
padCronEvery ( $words ) 'day at 03:00', '5 minutes', 'monday at 08:00' … as cron
padScheduleDue, padScheduleList, padScheduleRun The entries of _schedule.php due, listed, run
padCurlFake ( $fakes ) Every fetch of the request answered from the fakes - pattern => answer, status or closure; '*' for the rest
padCurlRecorded (), padCurlFakeStop (), padCurlFaking () The calls made; faking ended; whether it is on

Maintenance and assets

pad/lib/maintenance.php, pad/lib/asset.php.

Function Answers
padMaintenanceDown ( $app, $secret, $retry, $message ), padMaintenanceUp ( $app ) Takes an application down - what pad down does - and back up
padMaintenanceRead ( $app ), padMaintenanceList () Its record; the applications that are down
padAsset ( $file ), padAssetTag ( $file ) The versioned address of a file of www/<app>/; the element that loads it