Pop PHP
The Toolkit

Utilities & Helpers

pop-utils is the grab bag the rest of the framework is built on: static helpers for strings, arrays and numbers, the collection type every multi-row query hands back, array objects, dates, UUIDs and file metadata, and the callable wrapper behind the service locator and the event manager.

BASH
composer require popphp/pop-utils

Strings#

Pop\Utils\Str is static, and the piece you reach for most is case conversion. The method names are dynamic — Str::<from>To<To>() — across seven forms: TitleCase, camelCase, kebab-case (also spelled Dash), snake_case (also spelled Underscore), Name\Space, folder/path, and url/path (also spelled Uri).

PHP
use Pop\Utils\Str;

Str::titleCaseToKebabCase('TitleCase');         // 'title-case'
Str::titleCaseToSnakeCase('TitleCase');         // 'title_case'
Str::camelCaseToDash('camelCase');              // 'camel-case'
Str::camelCaseToUnderscore('camelCase');        // 'camel_case'
Str::kebabCaseToTitleCase('kebab-string');      // 'KebabString'
Str::snakeCaseToCamelCase('snake_case_string'); // 'snakeCaseString'
Str::snakeCaseToNamespace('snake_case_string'); // 'Snake\Case\String'
Str::kebabCaseToPath('kebab-string');           // 'Kebab/String'
Str::camelCaseToUrl('camelCase');               // 'camel/Case'

The path, namespace and URL conversions preserve the source string's casing, so kebabCaseToPath('kebab-string') gives Kebab/String. Pass false as the second argument for kebab/string.

Because the conversions are dispatched through __callStatic(), a name that is not a real pair does not throw a Pop\Utils\ExceptionStr::fooCaseToBarCase('x') fails with a raw TypeError: str_replace(): Argument #1 ($search) must be of type array|string, null given. Check the spelling of a conversion name against the list above rather than trusting a clean-looking call.

Slugs, links and random strings round out the class:

PHP
use Pop\Utils\Str;

Str::createSlug('Hello World | Home Page'); // 'hello-world-home-page'

Str::createRandom(10);                         // 10 chars including punctuation
Str::createRandomAlpha(10, Str::LOWERCASE);    // 'uwuctybahc'
Str::createRandomAlphaNum(10, Str::UPPERCASE); // 'E7KQC04961'

Str::stripSpecialCharacters('Hello_World! #1');                                    // 'Hello_World 1'
Str::stripSpecialCharacters('Hello_World! #1', alphaNumOnly: true, spaces: false); // 'HelloWorld1'

Str::createLinks() turns bare email addresses and URLs in a block of text into anchor tags — test@test.com becomes a mailto: link, http://www.test.com/ an href. It writes raw HTML, so run it over text you have already escaped, never over raw user input.

Arrays#

Pop\Utils\Arr is the static counterpart for arrays. Everything on it is a pure function: it takes an array and returns a new one, with Arr::pull() as the single exception — that one takes its array by reference and removes the key it returns.

PHP
use Pop\Utils\Arr;

Arr::collapse([[1, 2], [3, 4]]);        // [1, 2, 3, 4]
Arr::flatten([1, [2, [3, [4]]]]);       // [1, 2, 3, 4]
Arr::flatten([1, [2, [3, [4]]]], 1);    // [1, 2, [3, [4]]]
Arr::divide(['a' => 1, 'b' => 2]);      // [['a', 'b'], [1, 2]]
Arr::prepend([2, 3], 1);                // [1, 2, 3]
Arr::join(['a', 'b', 'c'], ', ', ' and '); // 'a, b and c'
Arr::trim(['  a  ', ' b']);             // ['a', 'b']
Arr::make('x');                         // ['x']
Arr::split('a,b,c', ',');               // ['a', 'b', 'c']

collapse() merges one level of nested arrays; flatten() goes all the way down unless you cap the depth. join()'s optional third argument is the separator before the final element, which is the difference between a list and a sentence.

Three predicates answer the questions that come up before a branch:

PHP
use Pop\Utils\Arr;

Arr::isArray(['a']);       // true — an array or an ArrayAccess object
Arr::isAssoc(['a' => 1]);  // true — has at least one string key
Arr::isNumeric([1, 2]);    // true — keys are sequential integers
Arr::exists(['a' => 1], 'a'); // true — works on arrays and ArrayAccess alike
Arr::key(['a' => 1, 'b' => 2], 2); // 'b' — the key holding a given value

Sorting has six forms — sort(), sortDesc(), ksort(), ksortDesc(), usort() and uksort() — each taking the array and returning a sorted copy. The $assoc argument, true by default, decides whether keys are preserved:

PHP
use Pop\Utils\Arr;

Arr::sort(['c' => 3, 'a' => 1, 'b' => 2]); // ['a' => 1, 'b' => 2, 'c' => 3]
Arr::usort([3, 1, 2], fn($a, $b) => $a <=> $b, false);

Arr::slice() takes the array, a limit and then an offset — in that order, which is the reverse of PHP's own array_slice(). Arr::slice(['a', 'b', 'c', 'd'], 2, 1) returns ['b', 'c']: two elements starting at index one.

Arr::pull() is the mutating one, and worth calling out for that reason alone:

PHP
use Pop\Utils\Arr;

$options = ['driver' => 'mysql', 'host' => 'localhost'];

$driver = Arr::pull($options, 'driver'); // 'mysql'
// $options is now ['host' => 'localhost']

Every method here accepts a Pop\Utils\AbstractArray as well as a plain array, so an ArrayObject or a Collection can be passed straight in without unwrapping it first.

Collections#

Pop\Utils\Collection is the type you meet without going looking for it. Every pop-db call that can return more than one row hands back a Pop\Db\Record\Collection, which extends it — so the object a findAll() gives you has all of this on it:

PHP
use Pop\Db\Record;

class SiteUser extends Record {}

$users = SiteUser::findAll(['order' => 'id ASC']);

$emails = $users->column('email')->values()->toArray();

That subclass adds exactly two methods — a toArray() that knows how to unwrap Record objects into arrays, and a getItems() — and inherits the rest, so everything below applies to both.

A collection is array-like on the outside: countable, iterable, and readable by offset.

PHP
use Pop\Utils\Collection;

$people = new Collection([
    ['name' => 'Nick', 'age' => 40],
    ['name' => 'Jane', 'age' => 35],
    ['name' => 'Amy',  'age' => 28],
]);

count($people);        // 3
$people[0]['name'];    // 'Nick'
$people->first();      // ['name' => 'Nick', 'age' => 40]
$people->last();       // ['name' => 'Amy', 'age' => 28]
$people->isEmpty();    // false
$people->has(0);       // true

foreach ($people as $person) {
    echo $person['name'];
}

The constructor takes an array, another Collection, an \ArrayObject, any \Traversable, or any object exposing its own toArray().

The transforming methods return a new collection and leave the original alone, so they chain:

PHP
use Pop\Utils\Collection;

$people = new Collection([
    ['name' => 'Nick', 'age' => 40],
    ['name' => 'Jane', 'age' => 35],
    ['name' => 'Amy',  'age' => 28],
]);

$names = $people->column('name')->values()->toArray();   // ['Nick', 'Jane', 'Amy']

$adults = $people->filter(fn($p) => $p['age'] > 30)->values();

$shouted = $people->map(fn($p) => strtoupper($p['name']))->toArray();

Filtering, Mapping and Paging#

values() in those chains is not decoration. filter(), slice() and forPage() all preserve the original keys, so filtering three rows down to the third gives you a collection keyed [2 => ...], and toArray() on it produces a JSON object rather than an array. Call values() before serializing anything that has been filtered.

each() walks the collection for a side effect rather than a result, and returning false from the callback stops the walk early:

PHP
use Pop\Utils\Collection;

$people = new Collection([['name' => 'Nick'], ['name' => 'Jane']]);

$names = [];

$people->each(function (array $person) use (&$names): void {
    $names[] = $person['name'];
});

forPage($page, $perPage) pages a collection you already hold in memory, and every($step, $offset) takes every nth element. merge() folds another array or collection in and returns a new one, join() renders to a string with an optional different separator before the last element, and contains() and has() answer membership by value and by key.

PHP
use Pop\Utils\Collection;

$people = new Collection([
    ['name' => 'Nick', 'age' => 40],
    ['name' => 'Jane', 'age' => 35],
    ['name' => 'Amy',  'age' => 28],
]);

$people->forPage(2, 2)->values()->toArray(); // the third row, on page two of two per page
$people->every(2)->toArray();                // rows one and three

$people->column('name')->join(', ', ' and '); // 'Nick, Jane and Amy'

Not every method leaves the original alone, and the split is not where the README's "most methods return a new instance" suggests:

Method Returns Original
filter(), map(), column(), values(), keys(), merge(), slice(), forPage(), every() A new Collection Untouched
each() The same Collection Untouched
sort(), sortDesc(), ksort(), ksortDesc(), usort(), uksort(), push() The same Collection Mutated in place
pop(), shift() The removed element Mutated in place
splice() A new Collection Mutated in place

flip() flips the keys and values of each item, so a collection of rows comes back as a collection of rows with their columns inverted. Use array_flip() on toArray() to invert the collection itself.

Collection does not implement JsonSerializable, so encode $collection->toArray() — or call jsonSerialize() — when you need JSON.

Array Objects, Dates, UUIDs and Files#

Pop\Utils\ArrayObject is the lighter sibling of Collection: array data readable by both array and object notation, countable and iterable, with JSON and PHP-serialization round trips.

PHP
use Pop\Utils\ArrayObject;

$arrayObject = new ArrayObject(['foo' => 'bar']);

$arrayObject->foo;      // 'bar'
$arrayObject['foo'];    // 'bar'
count($arrayObject);    // 1
$arrayObject->toArray();

ArrayObject::createFromJson('{"foo":"bar"}');
ArrayObject::createFromSerialized('a:1:{s:3:"foo";s:3:"bar";}');

$arrayObject->jsonSerialize() returns the JSON string — optionally with flags such as JSON_PRETTY_PRINT — and serialize() returns the PHP-serialized form, so the two factories and these two methods are a matched pair in each direction.

The cursor methods (first(), next(), current(), last(), key(), contains()) and the six sorting methods ArrayObject shares with Collection come from their common parent Pop\Utils\AbstractArray. On both classes the sorts mutate in place and return the same object, which is the one behavior worth keeping in mind when moving between the two types.

PHP
use Pop\Utils\ArrayObject;

$arrayObject = new ArrayObject(['b' => 2, 'a' => 1]);

$arrayObject->ksort(); // the object itself is now sorted; nothing new was returned

Pop\Utils\AbstractModel is an empty subclass of ArrayObject, there so an application's models share one ancestor while inheriting all of that behavior — array and object access, iteration, counting and the serialization round trips — without writing any of it.

Dates#

Pop\Utils\DateTime extends PHP's \DateTime and adds four things: it auto-detects more input formats, so new DateTime('08/24/2026') works; it totals and averages HH:MM:SS durations; it returns the dates of any week of any year; and it carries default formats so the object casts to a string:

PHP
use Pop\Utils\DateTime;

$times = ['08:45:18', '15:13:58', '09:05:09'];

DateTime::getTotal($times, '%H:%I:%S');   // '33:04:25'
DateTime::getAverage($times, '%H:%I:%S'); // '11:01:28'

DateTime::getWeekDates(40, 2023, 'Y-m-d'); // the seven dates of week 40

DateTime::isDst('2023-07-04'); // true — US rules unless you pass a window
DateTime::isDst('2023-01-04'); // false

$dateTime = DateTime::create('2026-08-24 14:32:10', null, 'Y-m-d', 'H:i:s');

echo $dateTime; // '2026-08-24 14:32:10'

Set a default format on a Pop\Utils\DateTime before casting it to a string.

UUIDs#

Pop\Utils\Uuid generates identifiers:

PHP
use Pop\Utils\Uuid;

Uuid::v4(); // '5bd40425-489b-4cef-acf6-6d0b06a52cfa' — random
Uuid::v7(); // '01a0358b-365f-7a5c-bafa-5616ba12475a' — time-ordered

if (Uuid::v4LinuxAvailable()) {
    Uuid::v4Linux(); // read from the kernel's UUID file instead
}

v7() puts a millisecond timestamp in the leading bits, so values sort chronologically as strings and cluster in an index instead of scattering the way v4() does — the one to reach for on a primary key.

File Metadata#

Pop\Utils\File reads a path's metadata without reading the file:

PHP
use Pop\Utils\File;

$file = new File(__DIR__ . '/../var/notes.txt');

$file->getBasename();  // 'notes.txt'
$file->getFilename();  // 'notes'
$file->getExtension(); // 'txt'
$file->getMimeType();  // 'text/plain'
$file->getSize();      // 13
$file->exists();       // true
$file->getContents();  // reads the file — the one method that does

The mime type is looked up from the extension rather than sniffed, falling back to application/octet-stream. So File::isImage('photo.jpg') and its siblings work on a filename with no file behind it, which also means they do not validate what a user uploaded. A File on a missing path still fills in the extension and mime type, with exists() false and getSize() 0.

File::formatFileSize(1572864) returns '1.57 MB', and File::getFileMimeType($path) is the one-call form of the lookup.

Deferring Construction with Callables#

Some work has to be described now and run later — a service built on demand, a listener on an event, a closure the database runs inside a transaction. Pop\Utils\CallableObject holds what to call and what to call it with, and does neither until you say call().

PHP
use Pop\Utils\CallableObject;

$callable = new CallableObject('trim', ' Hello World!');

$callable->call(); // 'Hello World!'

Parameters can arrive at any point in the object's life — in the constructor, through the fluent setters afterward, or at the moment of the call:

PHP
use Pop\Utils\CallableObject;

$callable = new CallableObject(function (string $var): string {
    return strtoupper($var) . '!';
});

$callable->addParameter('hello world');
$callable->call();              // 'HELLO WORLD!'

$other = new CallableObject(function (string $var): string {
    return strtoupper($var) . '!';
});

$other->call('hello world');    // the same result, bound at call time

That's the whole point of the class: the callable and its arguments are configurable data until the moment they are not.

Five spellings cover what you can wrap. 'trim' is a function, a closure is itself, 'Maths::double' is a static call, 'Maths->triple' constructs an instance and calls the method on it, and a bare class name — or 'new Maths', which means the same thing — constructs the object and hands it back:

PHP
use Pop\Utils\CallableObject;

class Greeter
{
    public function __construct(protected string $name) {}

    public function greet(): string
    {
        return 'Hello, ' . $this->name;
    }
}

$callable = new CallableObject('Greeter', 'World');

$greeter = $callable->call(); // a Greeter instance, constructed now and not before
$greeter->greet();            // 'Hello, World'

Arguments can be named as well as positional, which matters when a method's signature is long enough that position stops being obvious:

PHP
use Pop\Utils\CallableObject;

class Finder
{
    public function find(int $id, string $status): string
    {
        return $id . '/' . $status;
    }
}

$callable = new CallableObject('Finder->find');

$callable->addNamedParameter('id', 123)->addParameter('active');
$callable->call(); // '123/active'

A parameter that is itself callable — a closure, a plain callable, or another CallableObject — is invoked when the outer one is called, and its return value is what gets passed in. That's how a service takes a dependency that should not be built until the service is.

isCallable() resolves the target and reports whether it can actually be called, getCallableType() names what it resolved to, and wasCalled() says whether call() has run. Probe with isCallable() before calling something you're unsure of — it throws Pop\Utils\Exception naming the problem, where call() fails with a raw PHP Error.

This is what the rest of the framework is doing under the hood. Pop\Service\Locator::set(), Pop\Event\Manager::on() and pop-db's AbstractAdapter::transaction() and listen() all take whatever callable you hand them and wrap it in a CallableObject — and every one of them checks first whether you already built one, using yours as-is when you did:

PHP
use Pop\Service\Locator;
use Pop\Utils\CallableObject;

class Reporter
{
    public function __construct(protected string $format) {}
}

$locator = new Locator();

$locator->set('reporter', new CallableObject('Reporter', 'pdf'));

$locator['reporter']; // constructed on this first access, not before

Registering a plain closure would work identically; building the CallableObject yourself is what lets you configure and inspect the parameters before the service is ever constructed.

Type Coercion and Helpers#

Pop\Utils\Num turns numbers into the strings a person reads. Every method returns a string, never a number, so the result is for display and not for further arithmetic:

PHP
use Pop\Utils\Num;

Num::float(1234.5);             // '1234.50'
Num::currency(1234.5);          // '$1,234.50'
Num::percentage(12.345);        // '12.35%'
Num::convertPercentage(0.1234); // '12.34%'
Num::abbreviate(1234567);       // '1.23M'
Num::readable(1234567);         // '1 Million'

percentage() formats a number that is already a percentage; convertPercentage() multiplies a fraction by a hundred first. Each takes a precision and separator arguments for the conventions of a given locale.

Coercing in the other direction — an unknown value into a known shape — is Arr::make(), which wraps a scalar in an array and leaves an array alone, and is_json(), which answers whether a string will parse before you try.

The helper functions are procedural shorthands for the static methods above, and they are not loaded until something asks for them:

PHP
use Pop\Utils\Helper;

if (!Helper::isLoaded()) {
    Helper::loadFunctions();
}

str_slug('Hello World | Home Page'); // 'hello-world-home-page'
str_to_camel('snake_case_string');   // 'snakeCaseString'
str_kebab_case('TitleCase');         // 'title-case'
str_snake_case('TitleCase');         // 'title_case'
str_title_case('kebab-string');      // 'KebabString'
str_from_camel('camelCase');         // 'camel-case'

array_collapse([[1, 2], [3]]);       // [1, 2, 3]
array_flatten([1, [2, [3]]]);        // [1, 2, 3]
array_join(['a', 'b', 'c'], ', ', ' and '); // 'a, b and c'

The full set covers string case conversion, slugs, random strings, is_json(), the Arr methods, and app_date() / app_time(), which format against the APP_TIMEZONE environment variable rather than the process default.

Pop\Application calls Helper::loadFunctions() during bootstrap unless the config carries 'helper_functions' => false, so in an application they are already there. In a script or a test that does not boot an application they are not, and Helper::isLoaded() returns false until you load them — the guard above is what makes the same code work in both places.

The Str, Arr and Num classes carry more static methods than this page names, and CallableObject has a fuller parameter API — see the pop-utils README.

See Also#

  • Records & the ORM — where the Collection you are holding came from
  • ModelsPop\Utils\AbstractModel as an application's model base class
  • Services — the service locator, and what it wraps your callable in
  • Events — the event manager, doing the same thing with a listener
  • Config ObjectstoArrayObject() returns the ArrayObject documented here
  • pop-utils README — every helper class, method by method