# PSL - PHP Standard Library > A standard library for PHP, inspired by hhvm/hsl: a consistent, centralized, well-typed set of APIs covering async, collections, networking, I/O, cryptography, terminal UI, and more, replacing PHP functions and primitives with safer, async-ready alternatives that error predictably. Install everything with `composer require php-standard-library/php-standard-library`, or install any component as a standalone package. Each link below is the full markdown documentation for that component. ## Basics - [Dict](https://php-standard-library.dev/6.3.0/dict/index.md): The Dict component provides functions for creating and transforming associative arrays (array`). Unlike Vec, Dict functions preserve keys, making them the right choice when the relationship between keys and values matters. Dict functions are type-safe replacements for PHP's `array_map`, `array_filter`, `array_combine`, `array_merge`, and similar functions, with consistent argument order and predictable behavior. ## Transforming Values and Keys ```php use Psl\Dict; use Psl\Str; // Map: transform values, keep keys intact Dict\map(['a' => 1, 'b' => 2, 'c' => 3], fn(int $v) => $v * 10); // ['a' => 10, 'b' => 20, 'c' => 30] // Map keys: transform keys, keep values intact Dict\map_keys(['a' => 1, 'b' => 2], fn(string $k) => Str\uppercase($k)); // ['A' => 1, 'B' => 2] // Map with key: callback receives both key and value Dict\map_with_key(['width' => 100, 'height' => 50], fn(string $k, int $v) => $k . '=' . $v); // ['width' => 'width=100', 'height' => 'height=50'] ``` ## Filtering ```php use Psl\Dict; use Psl\Str; // Filter by value (keys are preserved) Dict\filter(['a' => 1, 'b' => 0, 'c' => 3], fn(int $v) => $v > 0); // ['a' => 1, 'c' => 3] // Filter by key Dict\filter_keys(['admin' => true, 'guest' => false], fn(string $k) => $k !== 'guest'); // ['admin' => true] // Filter with both key and value Dict\filter_with_key(['foo' => 1, 'bar' => 2, 'baz' => 3], fn(string $k, int $v) => $v > 1 && Str\contains($k, 'a')); // ['bar' => 2, 'baz' => 3] // Filter nonnull by: keep values where the callback returns non-null (original values are preserved) Dict\filter_nonnull_by(['a' => 'hello', 'b' => '', 'c' => 'world'], fn(string $v) => $v !== '' ? $v : null); // ['a' => 'hello', 'c' => 'world'] // Map nonnull: transform values and discard null results (keys are preserved) Dict\map_nonnull([1, 2, 3], fn(int $v) => $v > 1 ? $v * 2 : null); // [1 => 4, 2 => 6] ``` ## Building Dicts ```php use Psl\Dict; use Psl\Str; // Associate: zip keys and values into a dict Dict\associate(['x', 'y', 'z'], [10, 20, 30]); // ['x' => 10, 'y' => 20, 'z' => 30] // From entries: build from [key, value] tuples Dict\from_entries([['name', 'Alice'], ['role', 'admin']]); // ['name' => 'Alice', 'role' => 'admin'] // From keys: generate values from keys using a function Dict\from_keys( ['sm', 'md', 'lg'], /** * @param 'sm'|'md'|'lg' $size * * @return 576|768|992 */ fn(string $size) => match ($size) { 'sm' => 576, 'md' => 768, 'lg' => 992, }, ); // ['sm' => 576, 'md' => 768, 'lg' => 992] // Reindex: re-key an iterable using a function applied to each value $users = [['id' => 42, 'name' => 'Alice'], ['id' => 7, 'name' => 'Bob']]; Dict\reindex($users, fn($user) => $user['id']); // [42 => ['id' => 42, 'name' => 'Alice'], 7 => ['id' => 7, 'name' => 'Bob']] // Pull: build a dict by deriving both new keys and new values Dict\pull( ['Alice', 'Bob'], fn(string $name) => Str\length($name), // value function fn(string $name) => Str\lowercase($name), // key function ); // ['alice' => 5, 'bob' => 3] ``` ## Merging and Selecting ```php use Psl\Dict; // Merge: combine multiple dicts (later values overwrite earlier ones) Dict\merge(['a' => 1, 'b' => 2], ['b' => 99, 'c' => 3]); // ['a' => 1, 'b' => 99, 'c' => 3] // Select keys: pick only the specified keys Dict\select_keys(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4], ['a', 'c']); // ['a' => 1, 'c' => 3] // Flip: swap keys and values Dict\flip(['a' => 1, 'b' => 2]); // [1 => 'a', 2 => 'b'] // Unique: remove entries with duplicate values (keeps the first occurrence's key) Dict\unique([1 => 'a', 2 => 'b', 3 => 'a']); // [1 => 'a', 2 => 'b'] ``` ## Sorting ```php use Psl\Dict; // Sort by values (keys are preserved) Dict\sort(['b' => 3, 'a' => 1, 'c' => 2]); // ['a' => 1, 'c' => 2, 'b' => 3] // Sort by keys Dict\sort_by_key(['banana' => 2, 'apple' => 1, 'cherry' => 3]); // ['apple' => 1, 'banana' => 2, 'cherry' => 3] ``` ## Grouping ```php use Psl\Dict; // Group by: partition values into buckets based on a key function $words = ['apple', 'avocado', 'banana', 'blueberry', 'cherry']; Dict\group_by($words, fn(string $w) => $w[0]); // ['a' => ['apple', 'avocado'], 'b' => ['banana', 'blueberry'], 'c' => ['cherry']] // Returning null from the key function excludes that element Dict\group_by([1, 2, 3, 4, 5], static function (int $n): null|string { if ($n <= 2) { return null; // exclude 1 and 2 } if (($n % 2) === 0) { return 'even'; } return 'odd'; }); // ['odd' => [3, 5], 'even' => [4]] ``` See [src/Psl/Dict/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/dict/src/Psl/Dict/) for the full API. --- # Foundation The `Foundation` package provides the base exceptions, `Ref`, and `invariant()` utilities used across all PSL components. ## Usage ```php use Psl\Ref; // Assert invariants: throws InvariantViolationException when the condition is false $items = ['apple', 'banana', 'cherry']; Psl\invariant($items !== [], 'Item list must not be empty.'); // Mutable reference wrapper: pass by reference without PHP's & syntax $counter = new Ref(0); $increment = /** * @param Ref $counter */ static function (Ref $counter, int $amount): void { $counter->value += $amount; }; $increment($counter, 5); $increment($counter, 3); echo $counter->value; // 8 ``` ## Key Concepts `invariant()` is PSL's assertion function. Unlike PHP's `assert()`, it is always evaluated (never stripped in production) and throws a typed exception (`InvariantViolationException`) rather than triggering an error. Use it to enforce preconditions that must never be violated. `Ref` is a generic mutable reference wrapper. It exists because PHP closures capture variables by value by default. Wrapping a value in `Ref` lets you share mutable state across closures without using `&` references. See [src/Psl/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/foundation/src/Psl/) for the full API. --- # Fun The `Fun` component provides functional programming utilities for composing, decorating, and controlling function execution. These small combinators let you build data pipelines, inject side effects without disrupting flow, and defer expensive computations until they are actually needed. ## Usage ### Piping Values Through Functions `pipe()` chains closures left-to-right. Each function receives the output of the previous one: ```php use Psl\Fun; use Psl\Str; $transform = Fun\pipe( static fn(string $s): string => Str\trim($s), static fn(string $s): string => Str\lowercase($s), static fn(string $s): string => Str\replace($s, ' ', '-'), ); $transform(' Hello World '); // 'hello-world' ``` ### Composing Two Functions `after()` connects two functions where the output type of the first can differ from the input type of the second: ```php use Psl\Fun; use Psl\Str; $strlen = Fun\after(static fn(string $s): string => Str\trim($s), static fn(string $s): int => Str\length($s)); $strlen(' hi '); // 2 ``` ### Side Effects with tap() `tap()` runs a callback for its side effect and returns the original value unchanged -- useful for logging or debugging inside a pipeline: ```php use Psl\Fun; use Psl\IO; use Psl\Str; $process = Fun\pipe( Fun\tap(static fn(string $v) => IO\write_error_line('input: %s', $v)), static fn(string $s): string => Str\uppercase($s), Fun\tap(static fn(string $v) => IO\write_error_line('output: %s', $v)), ); $process('hello'); // 'HELLO' (logs are emitted as a side effect) ``` ### Lazy Evaluation `lazy()` wraps an initializer so the value is computed once on first access, then cached: ```php use Psl\Fun; use Psl\Vec; $config = Fun\lazy(static fn(): array => Vec\fill(1000, 'value')); $config(); // computes the array $config(); // returns cached result ``` ### Conditional Logic `when()` returns one of two results based on a predicate: ```php use Psl\Fun; $classify = Fun\when( static fn(int $n): bool => $n >= 18, static fn(int $_n): string => 'adult', static fn(int $_n): string => 'minor', ); $classify(21); // 'adult' $classify(12); // 'minor' ``` ### Utility Functions `identity()` returns a closure that passes its argument through unchanged -- handy as a default transformer or no-op callback: ```php use Psl\Fun; $fn = Fun\identity(); $fn(42); // 42 ``` `rethrow()` returns a closure that rethrows any exception it receives -- useful as an error callback: ```php use Psl\Fun; $handler = Fun\rethrow(); try { $handler(new RuntimeException('boom')); } catch (RuntimeException $e) { echo $e->getMessage(); // 'boom' } ``` See [src/Psl/Fun/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/fun/src/Psl/Fun/) for the full API. --- # Iter The `Iter` component provides utility functions for inspecting and reducing iterables. Unlike Vec and Dict, which eagerly produce arrays, Iter functions work directly with any `iterable` -- arrays, generators, and iterators -- making them ideal for querying data without creating intermediate collections. ## When to Use Iter vs Vec/Dict Use **Vec** or **Dict** when you need a transformed array as output (mapping, filtering, sorting). Use **Iter** when you need to answer a question about an iterable (does it contain X? what is the first element? how many items?) or reduce it to a single value. ```php use Psl\Iter; use Psl\Vec; // Vec: "give me a new array with doubled values" Vec\map([1, 2, 3], fn($n) => $n * 2); // [2, 4, 6] // Iter: "what is the sum of these values?" Iter\reduce([1, 2, 3], fn($sum, $n) => $sum + $n, 0); // 6 ``` ## Querying ```php use Psl\Iter; // First and last element (null if empty) Iter\first([10, 20, 30]); // 10 Iter\last([10, 20, 30]); // 30 // Check for emptiness Iter\is_empty([]); // true Iter\is_empty([1]); // false // Count elements Iter\count([1, 2, 3]); // 3 // Contains: check if a value exists (strict equality) Iter\contains([1, 2, 3], 2); // true Iter\contains([1, 2, 3], '2'); // false (strict) // Contains key: check if a key exists Iter\contains_key(['a' => 1, 'b' => 2], 'a'); // true // Random: get a random element Iter\random([10, 20, 30, 40]); // e.g. 30 ``` ## Predicates ```php use Psl\Iter; // All: do all elements satisfy the predicate? (short-circuits on false) Iter\all([2, 4, 6], fn(int $n) => ($n % 2) === 0); // true Iter\all([2, 3, 6], fn(int $n) => ($n % 2) === 0); // false // Any: does at least one element satisfy the predicate? (short-circuits on true) Iter\any([1, 3, 5], fn(int $n) => $n > 4); // true Iter\any([1, 3, 5], fn(int $n) => $n > 9); // false ``` ## Searching ```php use Psl\Iter; use Psl\Str; // Search: find the first value matching a predicate Iter\search(['foo', 'bar', 'baz'], fn(string $v) => Str\starts_with($v, 'ba')); // 'bar' Iter\search([1, 2, 3], fn(int $v) => $v > 10); // null (not found) ``` ## Reducing ```php use Psl\Iter; // Reduce: fold an iterable into a single value Iter\reduce([1, 2, 3, 4], fn(int $carry, int $v) => $carry + $v, 0); // 10 // Reduce with keys: the callback also receives the key $cart = ['apple' => 2, 'banana' => 3]; $prices = ['apple' => 1.50, 'banana' => 0.75]; Iter\reduce_with_keys($cart, fn(float $total, string $item, int $qty) => $total + ($prices[$item] * $qty), 0.0); // 5.25 ``` ## Side Effects ```php use Psl\Iter; // Apply: execute a function on each element (returns void) Iter\apply(['alice', 'bob'], fn(string $name) => print "Hello, {$name}!\n"); // Prints: // Hello, alice! // Hello, bob! ``` ## Joining Two Iterables `Iter\merge_join_by` and `Iter\merge_join_by_key` perform a full outer join of two iterables, yielding an `EitherOrBoth` event for each element that is present on the left only, the right only, or both. The sorted variant is lazy and uses O(1) memory on first traversal; the keyed variant materializes the right-hand side into a lookup and streams the left. Both return a rewindable `Iter\Iterator` -- a second iteration replays the cached events without re-walking the inputs. ```php use Psl\Comparison\Order; use Psl\EitherOrBoth; use Psl\Iter; use Psl\Vec; // -- Sorted inputs: merge_join_by // Both inputs must already be sorted according to the comparator. Lazy, O(1) memory // on first traversal. The returned `Iter\Iterator` is rewindable. $events = Vec\map( Iter\merge_join_by([1, 2, 4], [2, 3, 4], static fn(int $a, int $b): Order => Order::from($a <=> $b)), static fn(EitherOrBoth\EitherOrBoth $e): string => $e->proceed( left: static fn(int $v): string => "left:{$v}", right: static fn(int $v): string => "right:{$v}", both: static fn(int $l, int $r): string => "both:{$l}={$r}", ), ); // ['left:1', 'both:2=2', 'right:3', 'both:4=4'] // -- Keyed inputs: merge_join_by_key // Inputs do not need to be sorted. O(|right|) memory. Ideal when records carry an id. /** @var list $incoming */ $incoming = [['id' => 1, 'name' => 'Ada'], ['id' => 3, 'name' => 'Grace']]; /** @var list $current */ $current = [['id' => 1, 'name' => 'Ada (old)'], ['id' => 2, 'name' => 'Linus']]; $changelog = Vec\map( Iter\merge_join_by_key( $incoming, $current, /** @param array{id: int, name: string} $r */ static fn(array $r): int => $r['id'], ), /** @param EitherOrBoth\EitherOrBoth $e */ static fn(EitherOrBoth\EitherOrBoth $e): string => $e->proceed( /** @param array{id: int, name: string} $r */ left: static fn(array $r): string => "+ {$r['id']} {$r['name']}", /** @param array{id: int, name: string} $r */ right: static fn(array $r): string => "- {$r['id']} {$r['name']}", /** * @param array{id: int, name: string} $n * @param array{id: int, name: string} $o */ both: static fn(array $n, array $o): string => "~ {$n['id']} {$o['name']} -> {$n['name']}", ), ); // ['~ 1 Ada (old) -> Ada', '- 2 Linus', '+ 3 Grace'] ``` See the [EitherOrBoth](#either-or-both) page for the shape of the events and patterns for consuming them. ## Rewindable Iterators Generators in PHP can only be iterated once. The `Iter\Iterator` class wraps a generator so it can be rewound and iterated multiple times without re-executing the generator. ```php use Psl\Iter; $gen = (function () { yield 'a'; yield 'b'; yield 'c'; })(); $iterator = Iter\rewindable($gen); Iter\count($iterator); // 3 $iterator->rewind(); Iter\first($iterator); // 'a' -- still accessible after counting ``` See [src/Psl/Iter/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/iter/src/Psl/Iter/) for the full API. --- # Math The `Math` component provides mathematical functions with strict typing and predictable error handling. Instead of PHP's loose math functions that silently return `false` or emit warnings, PSL's `Math` functions throw typed exceptions on invalid inputs and preserve numeric types through generics. ## Constants ```php use Psl\Math; Math\PI; // 3.141592653589793... Math\E; // 2.718281828459045... Math\INFINITY; // INF Math\NAN; // NAN Math\INT64_MAX; // 9_223_372_036_854_775_807 Math\INT32_MAX; // 2_147_483_647 ``` ## Usage ### Basic Arithmetic ```php use Psl\Math; Math\abs(-5); // 5 (preserves int|float type) Math\ceil(4.2); // 5.0 Math\floor(4.8); // 4.0 Math\round(3.456, 2); // 3.46 Math\sqrt(16.0); // 4.0 Math\div(7, 2); // 3 (integer division, throws on division by zero) ``` ### Clamping and Aggregation ```php use Psl\Math; Math\clamp(15, 0, 10); // 10 Math\clamp(-5, 0, 10); // 0 Math\sum([1, 2, 3, 4]); // 10 (int) Math\sum_floats([1.5, 2.5]); // 4.0 (float) Math\mean([2, 4, 6]); // 4.0 Math\median([1, 3, 5, 7]); // 4.0 Math\mean([]); // null (empty list) ``` ### Min, Max, and Comparisons ```php use Psl\Math; // From a list (returns null if empty) Math\max([3, 1, 4, 1, 5]); // 5 Math\min([3, 1, 4, 1, 5]); // 1 // From variadic arguments (requires at least two) Math\maxva(3, 1, 4, 1, 5); // 5 Math\minva(3, 1, 4, 1, 5); // 1 // By a custom comparison function $users = [['name' => 'Alice', 'age' => 30], ['name' => 'Bob', 'age' => 25]]; Math\max_by($users, fn($u) => $u['age']); // ['name' => 'Alice', 'age' => 30] Math\min_by($users, fn($u) => $u['age']); // ['name' => 'Bob', 'age' => 25] ``` ### Trigonometry All trigonometric functions work in radians: ```php use Psl\Math; Math\sin(Math\PI / 2); // 1.0 Math\cos(0.0); // 1.0 Math\tan(Math\PI / 4); // ~1.0 Math\asin(1.0); // ~PI/2 Math\atan2(1.0, 1.0); // ~PI/4 (two-argument arctangent) ``` ### Logarithms and Exponentiation ```php use Psl\Math; Math\log(Math\E); // 1.0 (natural logarithm) Math\log(100.0, 10.0); // 2.0 (log base 10) Math\exp(1.0); // ~E (e^1) // Throws on invalid input instead of returning -INF or NAN try { Math\log(-1.0); } catch (Math\Exception\InvalidArgumentException $e) { echo $e->getMessage() . "\n"; } ``` ### Base Conversion ```php use Psl\Math; Math\to_base(255, 16); // 'ff' Math\from_base('ff', 16); // 255 Math\base_convert('ff', 16, 2); // '11111111' ``` See [src/Psl/Math/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/math/src/Psl/Math/) for the full API. --- # Str The `Str` component provides Unicode-aware string functions that replace PHP's `mb_*` and standard string functions with a consistent, safe, and predictable API. All functions work with UTF-8 by default and handle multibyte characters correctly. No more juggling `strlen` vs `mb_strlen` or forgetting encoding parameters -- `Str` makes the right behavior the default. ## The Three-Tier Model PSL organizes string operations into three namespaces based on what a "character" means: - `Str` (default): Unicode codepoints via `mb_*`. Use this for most work. A character is one Unicode codepoint. - `Str\Byte`: Raw bytes via standard PHP functions. Faster when you know content is ASCII-only. A character is one byte. - `Str\Grapheme`: Grapheme clusters via `grapheme_*`. Handles combined characters like accented letters and emoji. A character is one visual unit. The difference matters for non-ASCII text: ```php use Psl\Str; $emoji = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; // family emoji Str\Byte\length($emoji); // 18 (raw bytes) Str\length($emoji); // 5 (Unicode codepoints) Str\Grapheme\length($emoji); // 1 (one visual character) ``` **Rule of thumb**: Use `Str\` by default. Use `Str\Byte\` for ASCII-only performance. Use `Str\Grapheme\` when you need to count or split by what users see on screen. ## Usage ### Searching and Matching ```php use Psl\Str; Str\contains('Hello, World', 'World'); // true Str\contains('Hello, World', 'world'); // false (case-sensitive) Str\contains_ci('Hello, World', 'world'); // true (case-insensitive) Str\starts_with('Hello, World', 'Hello'); // true Str\ends_with('Hello, World', 'World'); // true // Find position of a substring (returns null if not found) Str\search('hello', 'l'); // 2 Str\search_last('hello', 'l'); // 3 ``` ### Extracting Substrings ```php use Psl\Str; Str\slice('Hello, World', 7); // 'World' Str\slice('Hello, World', 0, 5); // 'Hello' Str\before('user@example.com', '@'); // 'user' Str\after('user@example.com', '@'); // 'example.com' Str\chunk('Hello', 2); // ['He', 'll', 'o'] ``` ### Transforming ```php use Psl\Str; Str\uppercase('hello'); // 'HELLO' Str\lowercase('HELLO'); // 'hello' Str\capitalize('hello world'); // 'Hello world' Str\capitalize_words('hello world'); // 'Hello World' Str\replace('hello world', 'world', 'PHP'); // 'hello PHP' Str\replace_every('aabbcc', ['aa' => '1', 'cc' => '3']); // '1bb3' Str\reverse('Hello'); // 'olleH' Str\repeat('ab', 3); // 'ababab' ``` ### Splitting and Joining ```php use Psl\Str; Str\split('one,two,three', ','); // ['one', 'two', 'three'] Str\split('one,two,three', ',', 2); // ['one', 'two,three'] Str\join(['one', 'two', 'three'], ', '); // 'one, two, three' ``` ### Trimming and Padding ```php use Psl\Str; Str\trim(' hello '); // 'hello' Str\trim_left(' hello '); // 'hello ' Str\trim_right(' hello '); // ' hello' Str\pad_left('42', 5, '0'); // '00042' Str\pad_right('hi', 10, '.'); // 'hi........' ``` ### Measuring, Formatting, and More ```php use Psl\Str; Str\length('Hello'); // 5 Str\length('مرحبا'); // 5 (Arabic characters, 5 codepoints) Str\is_empty(''); // true Str\width('你好'); // 4 (CJK characters count as 2) Str\format('Hello, %s! You have %d messages.', 'Alice', 5); // 'Hello, Alice! You have 5 messages.' Str\concat('Hello', ', ', 'World'); // 'Hello, World' Str\truncate('Hello, World', 0, 8, '...'); // 'Hello...' Str\strip_prefix('HelloWorld', 'Hello'); // 'World' Str\strip_suffix('HelloWorld', 'World'); // 'Hello' ``` ## Byte-Level Operations When working with ASCII-only content, `Str\Byte\` functions are faster since they skip multibyte handling: ```php use Psl\Str; // All the same functions, operating on raw bytes Str\Byte\length('Hello'); // 5 Str\Byte\contains('Hello', 'ell'); // true Str\Byte\uppercase('hello'); // 'HELLO' Str\Byte\slice('Hello', 1, 3); // 'ell' Str\Byte\reverse('Hello'); // 'olleH' Str\Byte\rot13('Hello'); // 'Uryyb' ``` ## Grapheme-Level Operations When you need to handle combined characters correctly (emoji sequences, accented characters), use `Str\Grapheme\`: ```php use Psl\Str; $text = "cafe\u{0301}"; // 'cafe' + combining accent = 'caf' + visual 'é' Str\length($text); // 5 (codepoints) Str\Grapheme\length($text); // 4 (visual characters) Str\Grapheme\slice($text, 0, 3); // 'caf' (3 visual characters) ``` See [src/Psl/Str/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/str/src/Psl/Str/) for the full API. --- # Vec The `Vec` component provides functions for creating and transforming sequential, 0-indexed arrays (`list`). Every Vec function returns a re-indexed list, making them a type-safe, predictable replacement for PHP's `array_map`, `array_filter`, `array_values`, and similar functions. Unlike PHP's built-in array functions, Vec functions guarantee consistent argument order (iterable first, callback second), never preserve keys, and always produce a `list`. ## Transforming Values ```php use Psl\Vec; // Map: transform every element Vec\map([1, 2, 3], fn(int $n) => $n * 2); // [2, 4, 6] // Map with key: the callback receives both key and value Vec\map_with_key(['a', 'b', 'c'], fn(int $i, string $v) => $i . ':' . $v); // ['0:a', '1:b', '2:c'] // Filter: keep elements matching a predicate Vec\filter([1, 2, 3, 4, 5], fn(int $n) => $n > 3); // [4, 5] // Filter nulls: remove null values from a list Vec\filter_nulls([1, null, 3, null, 5]); // [1, 3, 5] // Filter nonnull by: keep values where the callback returns non-null (original values are preserved) Vec\filter_nonnull_by(['hello', '', 'world'], fn(string $v) => $v !== '' ? $v : null); // ['hello', 'world'] // Map nonnull: transform values and discard null results Vec\map_nonnull([1, 2, 3], fn(int $v) => $v > 1 ? $v * 2 : null); // [4, 6] ``` ## Sorting ```php use Psl\Str; use Psl\Vec; // Sort in ascending order Vec\sort([3, 1, 2]); // [1, 2, 3] // Sort with a custom comparator Vec\sort(['banana', 'apple', 'cherry'], fn($a, $b) => Str\length($a) <=> Str\length($b)); // ['apple', 'banana', 'cherry'] // Sort by a derived value $users = [['name' => 'Charlie', 'age' => 30], ['name' => 'Alice', 'age' => 25]]; Vec\sort_by($users, fn($u) => $u['age']); // [['name' => 'Alice', 'age' => 25], ['name' => 'Charlie', 'age' => 30]] ``` ## Combining and Splitting ```php use Psl\Str; use Psl\Vec; // Concat: join multiple iterables into one list Vec\concat([1, 2], [3, 4], [5]); // [1, 2, 3, 4, 5] // Flatten: merge a list of lists into a single list Vec\flatten([[1, 2], [3], [4, 5]]); // [1, 2, 3, 4, 5] // Flat map: map then flatten in one step Vec\flat_map(['hello world', 'foo bar'], fn($s) => Str\split($s, ' ')); // ['hello', 'world', 'foo', 'bar'] // Zip: pair up elements from two lists Vec\zip(['a', 'b', 'c'], [1, 2, 3]); // [['a', 1], ['b', 2], ['c', 3]] // Chunk: split into groups of a given size Vec\chunk([1, 2, 3, 4, 5], 2); // [[1, 2], [3, 4], [5]] // Partition: split into two lists based on a predicate Vec\partition([1, 2, 3, 4, 5], fn($n) => ($n % 2) === 0); // [[2, 4], [1, 3, 5]] ``` ## Generating and Reshaping ```php use Psl\Vec; // Range: generate a sequence of numbers Vec\range(1, 5); // [1, 2, 3, 4, 5] Vec\range(0.0, 1.0, 0.5); // [0.0, 0.5, 1.0] // Reproduce: generate values using a factory Vec\reproduce(3, fn(int $i) => $i * $i); // [1, 4, 9] // Unique: remove duplicate values Vec\unique([1, 2, 2, 3, 3, 3]); // [1, 2, 3] // Reverse: flip the order Vec\reverse([1, 2, 3]); // [3, 2, 1] // Enumerate: convert key-value pairs into a list of [key, value] tuples Vec\enumerate(['a' => 1, 'b' => 2]); // [['a', 1], ['b', 2]] // Keys/Values: extract keys or values as a list Vec\keys(['x' => 10, 'y' => 20]); // ['x', 'y'] Vec\values(['x' => 10, 'y' => 20]); // [10, 20] ``` ## Key Difference from Dict Vec always re-indexes the result. If you filter an associative array with `Vec\filter`, the keys are discarded and the result is a sequential `list`. If you need to preserve keys, use `Dict\filter` instead. ```php use Psl\Dict; use Psl\Vec; $data = ['a' => 1, 'b' => 2, 'c' => 3]; Vec\filter($data, fn($v) => $v > 1); // [2, 3] -- keys dropped, re-indexed as list Dict\filter($data, fn($v) => $v > 1); // ['b' => 2, 'c' => 3] -- keys preserved ``` See [src/Psl/Vec/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/vec/src/Psl/Vec/) for the full API. --- # Comparison The `Comparison` component provides interfaces and functions for comparing values in a type-safe, consistent way. Instead of relying on PHP's loose comparison rules or scattering `<=>` operators throughout your code, you implement a `Comparable` interface and get a full suite of comparison operations for free. ## Design - **`Comparable`** -- Interface for types that can be ordered relative to each other - **`Equable`** -- Interface for types that can be checked for equality - **`Order`** -- Enum with three cases: `Less`, `Equal`, `Greater` - **Comparison functions** -- `compare()`, `equal()`, `less()`, `greater()`, and more ## Usage ### The Order Enum `Order` replaces magic integers (`-1`, `0`, `1`) with readable, type-safe cases: ```php use Psl\Comparison\Order; use Psl\IO; // Order replaces magic integers (-1, 0, 1) with readable, type-safe cases IO\write_error_line('Order::Less = %d', Order::Less->value); // -1 - first value is smaller IO\write_error_line('Order::Equal = %d', Order::Equal->value); // 0 - values are equal IO\write_error_line('Order::Greater = %d', Order::Greater->value); // 1 - first value is larger ``` ### Comparing Values The `compare()` function works with any values. For types implementing `Comparable`, it delegates to their `compare()` method. For everything else, it falls back to PHP's `<=>` operator: ```php use Psl\Comparison; Comparison\compare(1, 2); // Order::Less Comparison\compare('b', 'a'); // Order::Greater Comparison\equal(42, 42); // true Comparison\less(1, 2); // true Comparison\greater_or_equal(3, 3); // true ``` ### Implementing Comparable Make your classes sortable and comparable by implementing the `Comparable` interface: ```php use Psl\Comparison; final readonly class Money implements Comparison\Comparable, Comparison\Equable { public function __construct( private int $cents, private string $currency, ) {} public function compare(mixed $other): Comparison\Order { if ($this->currency !== $other->currency) { throw Comparison\Exception\IncomparableException::fromValues( $this, $other, 'Cannot compare different currencies', ); } return Comparison\compare($this->cents, $other->cents); } public function equals(mixed $other): bool { return Comparison\equal($this, $other); } } $a = new Money(500, 'USD'); $b = new Money(1000, 'USD'); Comparison\less($a, $b); // true Comparison\greater($b, $a); // true Comparison\equal($a, $a); // true ``` ### Sorting with Comparable The `sort()` function returns an `int` suitable for PHP's `usort()` or PSL's `Vec\sort`: ```php use Psl\Comparison; use Psl\Vec; /** * @implements Comparison\Comparable * @implements Comparison\Equable */ final readonly class Money implements Comparison\Comparable, Comparison\Equable { public function __construct( private int $cents, private string $currency, ) {} /** * @param Money $other * * @throws Comparison\Exception\IncomparableException */ public function compare(mixed $other): Comparison\Order { if ($this->currency !== $other->currency) { throw Comparison\Exception\IncomparableException::fromValues( $this, $other, 'Cannot compare different currencies', ); } return Comparison\compare($this->cents, $other->cents); } /** * @param Money $other */ public function equals(mixed $other): bool { return Comparison\equal($this, $other); } } $prices = [new Money(1000, 'USD'), new Money(500, 'USD'), new Money(750, 'USD')]; $sorted = Vec\sort($prices, Comparison\sort(...)); // [Money(500), Money(750), Money(1000)] ``` ### Available Comparison Functions All functions work with both `Comparable` types and plain scalars: | Function | Description | |---|---| | `compare($a, $b)` | Returns `Order` enum | | `equal($a, $b)` | True if values are equal | | `not_equal($a, $b)` | True if values differ | | `less($a, $b)` | True if `$a < $b` | | `greater($a, $b)` | True if `$a > $b` | | `less_or_equal($a, $b)` | True if `$a <= $b` | | `greater_or_equal($a, $b)` | True if `$a >= $b` | | `sort($a, $b)` | Returns `int` for use as a sort callback | See [src/Psl/Comparison/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/comparison/src/Psl/Comparison/) for the full API. --- # Default The `Default` component introduces a uniform approach for classes within PSL to provide and utilize default instances. It encapsulates the `DefaultInterface`, establishing a contract that enables implementing classes to offer a standardized method for instantiating themselves with default values or settings. This pattern is beneficial when an object needs sensible defaults but also allows customization through further configuration. ## Usage ```php use Psl\Default\DefaultInterface; enum TransportMethod implements DefaultInterface { case Air; case Water; case Land; public static function default(): static { return self::Land; } } // Obtaining a default instance of TransportMethod $transportMethod = TransportMethod::default(); ``` Classes and enums that implement `DefaultInterface` provide a static `default()` method returning a new instance initialized with default values: ```php use Psl\Default; final class Example implements Default\DefaultInterface { public static function default(): static { // Return an instance with default configuration. return new self(); } } $example = Example::default(); ``` Implementing `DefaultInterface` signals that a class supports this standardized mechanism for obtaining default instances, ensuring consistency across PSL. See [src/Psl/Default/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/default/src/Psl/Default/) for the full API. --- # EitherOrBoth `EitherOrBoth` is a three-variant disjoint union: `Left`, `Right`, or `Both`. It models a value that may be present on either of two sides, or on both simultaneously. Unlike `Either` (which carries a loose "one vs the other" convention where `Right` is the happy path), `EitherOrBoth` treats all three variants as equal citizens. No side is privileged. The canonical use case is a three-way diff between two collections -- insert / delete / update events -- but the type is useful anywhere two partially-overlapping sources describe the same domain. ## Design - **`Left`** -- only the left value is present - **`Right`** -- only the right value is present - **`Both`** -- both sides are present simultaneously - **`EitherOrBoth`** -- the common interface all three implement Inspired by Rust's [`itertools::EitherOrBoth`](https://docs.rs/itertools/latest/itertools/enum.EitherOrBoth.html) and Haskell's `Data.These`. ## When to Use Reach for `EitherOrBoth` any time two collections, streams, or sources describe the same domain with potential partial overlap: - **Three-way diff / synchronization** -- the canonical producer. Pair with `Psl\Iter\merge_join_by` or `merge_join_by_key` to emit insert / delete / update events. - **Layered configuration merge** -- defaults vs overrides. `Left` = key only in defaults, `Right` = key only in overrides, `Both(default, override)` = key in both (inspect the conflict before resolving). - **Multi-source enrichment** -- a primary record paired with optional data from a cache or secondary store. - **Dual-validation** -- two parallel validators that can both fail, where the handling differs. - **Snapshot comparison** -- expected vs actual, current vs desired, old-format vs new-format. - **Full outer join at the language level** -- any time a SQL `FULL OUTER JOIN` is the mental model. ## Usage ### Creating EitherOrBoth Values Use the concrete classes or the `left()` / `right()` / `both()` free functions: ```php use Psl\EitherOrBoth; // Via constructors $onlyNew = new EitherOrBoth\Left('new-record'); $onlyOld = new EitherOrBoth\Right('old-record'); $conflict = new EitherOrBoth\Both('new-version', 'old-version'); // Via free functions $onlyNew = EitherOrBoth\left('new-record'); $onlyOld = EitherOrBoth\right('old-record'); $conflict = EitherOrBoth\both('new-version', 'old-version'); ``` ### Predicates: `is*` vs `has*` `EitherOrBoth` has two tiers of predicates. The exclusive `isLeft()` / `isRight()` / `isBoth()` each return true for exactly one variant. The inclusive `hasLeft()` / `hasRight()` return true whenever the corresponding side is present -- so `hasLeft()` is true for both `Left` and `Both`. ```php use Psl\EitherOrBoth; $both = new EitherOrBoth\Both('l', 'r'); // Exclusive predicates: true for exactly one variant $both->isLeft(); // false $both->isRight(); // false $both->isBoth(); // true // Inclusive predicates: true whenever the side is present $both->hasLeft(); // true -- Both has a left side $both->hasRight(); // true -- Both has a right side $leftOnly = new EitherOrBoth\Left('l'); $leftOnly->isLeft(); // true $leftOnly->hasLeft(); // true $leftOnly->hasRight(); // false ``` ### Extracting Values ```php use Psl\EitherOrBoth; $both = new EitherOrBoth\Both('hello', 'world'); // Direct access -- throws if the side is missing $both->getLeft(); // 'hello' $both->getRight(); // 'world' $left = new EitherOrBoth\Left('only-left'); $left->getLeft(); // 'only-left' // $left->getRight(); // throws MissingRightException // Safe access via Option $left->unwrapLeft(); // Some('only-left') $left->unwrapRight(); // None // Option methods cover the "or default" variations without duplicate surface on EitherOrBoth: $left->unwrapRight()->unwrapOr('fallback'); // 'fallback' $left->unwrapLeft()->unwrapOrElse(static fn(): string => 'computed'); // 'only-left' ``` ### Transforming Values `mapLeft()` and `mapRight()` each transform one side, leaving the other untouched. On `Both`, only the addressed side changes. `mapAny()` applies two closures, one per side. `map()` applies the same closure to whichever side(s) are present -- useful when both sides have the same type: ```php use Psl\EitherOrBoth; use Psl\Str; $both = new EitherOrBoth\Both('hello', 'world'); // mapLeft / mapRight: transform one side, leave the other untouched $both->mapLeft(Str\uppercase(...)); // Both('HELLO', 'world') $both->mapRight(Str\uppercase(...)); // Both('hello', 'WORLD') // mapAny: two closures, one per side. On Both, both run. $both->mapAny(Str\uppercase(...), Str\length(...)); // Both('HELLO', 5) // map: same closure applied to whichever side(s) are present. // On Both, the closure runs twice. $both->map(Str\uppercase(...)); // Both('HELLO', 'WORLD') // mapLeft on a Right is a no-op and returns the same instance: $right = new EitherOrBoth\Right('unchanged'); $right->mapLeft(Str\uppercase(...)); // still Right('unchanged'), same object ``` ### Pattern Matching with proceed() `proceed()` takes three closures -- one per variant -- and dispatches to exactly one. Arguments are positional: `left`, `right`, then `both` (no happy-path convention applies here, since the three variants are equal citizens): ```php use Psl\EitherOrBoth; /** @var EitherOrBoth\EitherOrBoth $event */ $event = new EitherOrBoth\Both('new-value', 'old-value'); $description = $event->proceed( left: static fn(string $new): string => "insert: {$new}", right: static fn(string $old): string => "delete: {$old}", both: static fn(string $new, string $old): string => "update: {$old} -> {$new}", ); // 'update: old-value -> new-value' ``` ### Swapping Sides `swap()` flips Left into Right and vice versa. On `Both(l, r)` it produces `Both(r, l)`: ```php use Psl\EitherOrBoth; $left = new EitherOrBoth\Left('hello'); $swappedLeft = $left->swap(); // Right('hello') $right = new EitherOrBoth\Right('world'); $swappedRight = $right->swap(); // Left('world') $both = new EitherOrBoth\Both('l', 'r'); $swappedBoth = $both->swap(); // Both('r', 'l') ``` ### Side Effects with apply() `apply()` runs a single side-effect closure on whichever side(s) are present and returns the value unchanged. On `Both` it runs twice, once per side -- same invocation shape as `map()`. Useful for logging inside a pipeline: ```php use Psl\EitherOrBoth; use Psl\IO; // Single-closure apply: runs on whichever side(s) are present. // On Left/Right the closure is called once; on Both it runs twice (once per side). $event = new EitherOrBoth\Both('new', 'old'); $event->apply(static fn(string $v): mixed => IO\write_error_line('value: %s', $v)); // Logs: // value: new // value: old // and returns the Both unchanged. ``` ### Composing with iter producers `EitherOrBoth` is the element type of `Psl\Iter\merge_join_by` (sorted inputs, O(1) memory on first traversal) and `Psl\Iter\merge_join_by_key` (keyed inputs, O(|right|) memory). Both return a rewindable `Iter\Iterator`. The four most common composition patterns -- side effects, pure mapping, filtering before dispatch, and per-side transformation -- all fall out naturally: ```php use Psl\EitherOrBoth; use Psl\Iter; use Psl\Vec; /** @var list $incoming */ $incoming = [['id' => 1, 'name' => 'Ada'], ['id' => 3, 'name' => 'Grace']]; /** @var list $current */ $current = [['id' => 1, 'name' => 'Ada (old)'], ['id' => 2, 'name' => 'Linus']]; // 1. Side effects (DB sync). `Iter\apply` is a drop-in for a foreach loop when // you do not need the loop variable after the iteration finishes. $ops = []; Iter\apply( Iter\merge_join_by_key( $incoming, $current, /** @param array{id: int, name: string} $r */ static fn(array $r): int => $r['id'], ), /** @param EitherOrBoth\EitherOrBoth $event */ static fn(EitherOrBoth\EitherOrBoth $event): mixed => $event->proceed( /** @param array{id: int, name: string} $new */ left: static fn(array $new) => $ops[] = ['insert', $new['id']], /** @param array{id: int, name: string} $old */ right: static fn(array $old) => $ops[] = ['delete', $old['id']], /** * @param array{id: int, name: string} $new * @param array{id: int, name: string} $old */ both: static fn(array $new, array $old) => $ops[] = ['update', $new['id'], $old['id']], ), ); // $ops: [['update', 1, 1], ['delete', 2], ['insert', 3]] // 2. Pure mapping (build a changelog line for each event) $changelog = Vec\map( Iter\merge_join_by_key( $incoming, $current, /** @param array{id: int, name: string} $r */ static fn(array $r): int => $r['id'], ), /** @param EitherOrBoth\EitherOrBoth $e */ static fn(EitherOrBoth\EitherOrBoth $e): string => $e->proceed( /** @param array{id: int, name: string} $r */ left: static fn(array $r): string => "+ {$r['id']} {$r['name']}", /** @param array{id: int, name: string} $r */ right: static fn(array $r): string => "- {$r['id']} {$r['name']}", /** * @param array{id: int, name: string} $n * @param array{id: int, name: string} $o */ both: static fn(array $n, array $o): string => "~ {$n['id']} {$o['name']} -> {$n['name']}", ), ); // ['~ 1 Ada (old) -> Ada', '- 2 Linus', '+ 3 Grace'] // 3. Filtering before applying (only care about deletes) $deletes = Vec\filter( Vec\values(Iter\merge_join_by_key( $incoming, $current, /** @param array{id: int, name: string} $r */ static fn(array $r): int => $r['id'], )), static fn(EitherOrBoth\EitherOrBoth $e): bool => $e->isRight(), ); // 4. Mapping each side independently (hydrate old + new differently) $hydrated = Vec\map( Iter\merge_join_by_key( $incoming, $current, /** @param array{id: int, name: string} $r */ static fn(array $r): int => $r['id'], ), /** @param EitherOrBoth\EitherOrBoth $e */ static fn(EitherOrBoth\EitherOrBoth $e): EitherOrBoth\EitherOrBoth => $e->mapAny( /** @param array{id: int, name: string} $new */ static fn(array $new): string => "NEW:{$new['name']}", /** @param array{id: int, name: string} $old */ static fn(array $old): string => "OLD:{$old['name']}", ), ); ``` ## When to Use EitherOrBoth vs Either - **Either** -- two mutually exclusive outcomes; one is the primary/success path, the other is secondary/error. - **EitherOrBoth** -- two sides that may independently be present, absent, or both present. No privileged side. If "both present" is a meaningful state in your domain, use `EitherOrBoth`. If it cannot occur, use `Either`. See [src/Psl/EitherOrBoth/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/either-or-both/src/Psl/EitherOrBoth/) for the full API. --- # Either The `Either` component represents a value that is one of two possible types: `Left` or `Right`. Unlike `Result` (which specifically models success/failure with exceptions), `Either` is a general-purpose disjoint union -- both sides carry arbitrary values. This is useful when an operation has two equally valid outcomes that aren't naturally "success" or "error", such as returning cached data vs. fresh data, or routing a value down one of two processing paths. By convention, `Left` represents the secondary/error case and `Right` represents the primary/success case, but this is only a convention -- both sides are first-class values. ## Design - **`Left`** -- Holds a value of the left type - **`Right`** -- Holds a value of the right type - **`Either`** -- The common interface both implement ## Usage ### Creating Either Values ```php use Psl\Either; $left = new Either\Left('not found'); $right = new Either\Right(42); $right->isRight(); // true $left->isLeft(); // true ``` ### Extracting Values ```php use Psl\Either; $either = new Either\Right('hello'); // Direct access (throws if wrong side) $either->getRight(); // 'hello' // $either->getLeft(); // throws RightException // Safe access with defaults $either->getRightOr('fallback'); // 'hello' $either->getLeftOr('fallback'); // 'fallback' // Lazy default -- only computed if needed $either->getRightOrElse(static fn(string $left): string => 'computed from: ' . $left); // Convert to Option $either->unwrapRight(); // Some('hello') $either->unwrapLeft(); // None ``` ### Transforming Values `mapRight()` transforms the right value, leaving a left untouched. `mapLeft()` does the opposite. `map()` transforms whichever side is present: ```php use Psl\Either; $parsed = new Either\Right('42'); $result = $parsed->mapRight(static fn(string $v): int => (int) $v)->mapRight(static fn(int $v): int => $v * 2); // Right(84) $error = new Either\Left('invalid input'); $error->mapRight(static fn(string $v): int => (int) $v); // Left('invalid input') -- unchanged ``` ### Chaining with flatMap When a transformation itself returns an `Either`, use `flatMapRight()` or `flatMapLeft()` to avoid nested `Either` values: ```php use Psl\Either; /** * @return Either\Either */ function parse_int(string $input): Either\Either { return is_numeric($input) ? new Either\Right((int) $input) : new Either\Left("'{$input}' is not a number"); } $result = new Either\Right('42')->flatMapRight(static fn(string $v): Either\Either => parse_int($v)); // Right(42) ``` ### Pattern Matching with proceed() Handle both cases and return a unified result. The right (happy-path) closure comes first: ```php use Psl\Either; /** @var Either\Either $either */ $either = new Either\Right(42); $message = $either->proceed( static fn(int $value): string => "Got value: {$value}", static fn(string $error): string => "Error: {$error}", ); // 'Got value: 42' ``` ### Swapping Sides `swap()` flips a `Left` into a `Right` and vice versa: ```php use Psl\Either; $left = new Either\Left('hello'); $right = $left->swap(); // Right('hello') ``` ### Side Effects with apply() Run a closure on the contained value without changing the `Either`: ```php use Psl\Either; use Psl\IO; use Psl\Str; $either = new Either\Right('some data') ->apply(static fn(string $v): mixed => IO\write_error_line('Processing value: %s', $v)) ->mapRight(static fn(string $v): string => Str\uppercase($v)); // Right('SOME DATA') ``` ## When to Use Either vs Result - **Result** -- The operation can succeed or throw. You want to capture exceptions as values. - **Either** -- The operation has two valid outcomes. Both sides carry meaningful domain values, not exceptions. ## When to Use Either vs EitherOrBoth If both sides can be present at the same time (for example, three-way diff events on two partially-overlapping collections), reach for [EitherOrBoth](#either-or-both) instead. `Either` is the right type when exactly one side is present; `EitherOrBoth` is the right type when either or both can be. See [src/Psl/Either/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/either/src/Psl/Either/) for the full API. --- # Option The `Option` component represents a value that may or may not be present. It replaces nullable types (`?T`) with explicit `Some`/`None` semantics, making the absence of a value intentional and impossible to ignore. Instead of returning `null` and hoping callers remember to check, you return an `Option` that forces the caller to handle both cases. ## Design `Option` is a single `readonly` class with two states: - **`Option::some($value)`** -- A value is present - **`Option::none()`** -- No value is present Convenience functions `some()`, `none()`, and `from_nullable()` are available in the `Psl\Option` namespace. ## Usage ### Creating Options ```php use Psl\Option; $present = Option\some(42); $absent = Option\none(); // Bridge from nullable values $option = Option\from_nullable('hello'); // null becomes None, anything else becomes Some $fromNull = Option\from_nullable(null); // $fromNull is None ``` ### Checking and Unwrapping ```php use Psl\Option; $opt = Option\some(42); $opt->isSome(); // true $opt->isNone(); // false $opt->isSomeAnd(fn(int $v) => $v > 10); // true $opt->contains(42); // true // Unwrap the value $opt->unwrap(); // 42 (throws NoneException if None) $opt->unwrapOr(0); // 42 (returns default if None) $opt->unwrapOrElse(fn() => 0); // 42 (lazy default if None) ``` ### Transforming Values ```php use Psl\Option; $opt = Option\some(5); // map: transform the inner value $opt->map(fn(int $v) => $v * 2); // Some(10) Option\none()->map(fn(int $v) => $v * 2); // None // andThen: chain operations that return Options (flatMap) Option\some('hello') ->andThen(fn(string $v) => Option\from_nullable($v !== '' ? $v : null)) ->andThen(fn(string $v) => Option\some(strlen($v))); // Some(5) if non-empty string, None otherwise // mapOr / mapOrElse: transform with a default Option\none()->mapOr(fn(int $v) => $v * 2, 0); // Some(0) ``` ### Pattern Matching with proceed() Handle both cases and return a unified result: ```php use Psl\Option; $username = 'Alice'; $greeting = Option\from_nullable($username)->proceed( fn(string $name) => "Welcome back, {$name}!", fn() => 'Welcome, guest!', ); ``` ### Side Effects with apply() Run a closure on the value without changing the Option: ```php use Psl\Option; $result = Option\some('admin') ->apply(fn(string $role) => print "Found role: {$role}\n") ->map(fn(string $role) => strtoupper($role)); ``` ### Filtering ```php use Psl\Option; Option\some(42)->filter(fn(int $v) => $v > 10); // Some(42) Option\some(42)->filter(fn(int $v) => $v > 100); // None ``` ### Combining Options ```php use Psl\Option; $a = Option\some(1); $b = Option\some(2); $none = Option\none(); $a->and($b); // Some(2) -- returns second if both Some $none->or($b); // Some(2) -- returns first Some found $none->orElse(fn() => Option\some(99)); // Some(99) // Zip: combine into a tuple $a->zip($b); // Some([1, 2]) $a->zip($none); // None // ZipWith: combine with a function $a->zipWith($b, fn(int $x, int $y) => $x + $y); // Some(3) // Unzip: split a tuple Option Option\some([1, 2])->unzip(); // [Some(1), Some(2)] Option\none()->unzip(); // [None, None] ``` ## When to Use Option - **Repository lookups**: `find_by_id()` returns `Option` instead of `?Entity` - **Configuration**: `get_setting()` returns `Option` -- absence is explicit - **Chained lookups**: Use `andThen()` to chain fallible lookups without nested null checks - **Safe data access**: Parse user input with `from_nullable()` and transform with `map()` See [src/Psl/Option/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/option/src/Psl/Option/) for the full API. --- # Result The `Result` component represents a computation that either succeeded or failed. Instead of letting exceptions propagate uncontrolled, you capture the outcome as a value that can be inspected, transformed, and passed around. This is useful when you want to defer error handling, aggregate results from multiple operations, or build transformation pipelines without scattering try/catch blocks throughout your code. ## Design - **`Success`** -- Wraps a successful return value of type `T` - **`Failure`** -- Wraps a `Throwable` from a failed operation - **`ResultInterface`** -- The common interface both implement ## Usage ### Wrapping Operations The `wrap()` function executes a closure and captures the outcome: ```php use Psl\Json; use Psl\Result; use Psl\Type; $result = Result\wrap( /** @return array */ fn(): array => Json\typed('{"name":"Alice"}', Type\dict(Type\string(), Type\string())), ); if ($result->isSucceeded()) { $data = $result->getResult(); } else { $error = $result->getThrowable(); } // Get the value with a fallback $data = $result->unwrapOr([]); ``` ### Transforming Results `map()` transforms a successful value, leaving failures untouched. `catch()` recovers from failures, leaving successes untouched: ```php use Psl\File; use Psl\Result; use Psl\Str; $lines = Result\wrap(fn() => File\read(__FILE__)) ->map(fn(string $content) => Str\split($content, "\n")) ->catch(fn(Throwable $_e) => []); // Success: maps content to lines // Failure: recovers with empty array ``` `then()` handles both cases at once, returning a new `ResultInterface`: ```php use Psl\Json; use Psl\Result; $normalized = Result\wrap(fn() => Json\decode('{"name":"Alice","age":30}')) ->then(fn(mixed $data) => ['status' => 'ok', 'data' => $data], fn(Throwable $e) => ['error' => $e->getMessage()]); // $normalized is ResultInterface, still wrapped ``` ### Pattern Matching with proceed() The `proceed()` method unwraps the result by calling the appropriate closure and returning the value directly (not wrapped in a Result): ```php use Psl\Iter; use Psl\Json; use Psl\Result; use Psl\Type; $message = Result\wrap(fn(): array => Json\typed('{"a":1,"b":2,"c":3}', Type\dict(Type\string(), Type\int()))) ->proceed( fn(array $data): string => 'Parsed: ' . Iter\count($data) . ' items', fn(Throwable $e): string => 'Failed: ' . $e->getMessage(), ); ``` ### Cleanup with always() Run cleanup logic regardless of outcome: ```php use Psl\File; use Psl\Filesystem; use Psl\Result; $temp = Filesystem\create_temporary_file(); File\write($temp, 'temporary data'); $result = Result\wrap(fn() => File\read($temp))->always(fn() => Filesystem\delete_file($temp)); ``` ### Convenience Functions `try_catch()` wraps and recovers in a single step: ```php use Psl\Json; use Psl\Result; $_ = Result\try_catch(fn() => Json\decode('not valid json'), fn(Throwable $_e) => []); ``` `reflect()` converts a throwing closure into one that returns a Result: ```php use Psl\Json; use Psl\Result; $safeParse = Result\reflect(fn() => Json\decode('{"key":"value"}')); $result = $safeParse(); // ResultInterface, never throws ``` ### Collecting Statistics When processing batches, `collect_stats()` summarizes outcomes: ```php use Psl\Json; use Psl\Result; use Psl\Vec; $inputs = ['{"a":1}', 'invalid', '{"b":2}', '{bad}', '{"c":3}']; $results = Vec\map($inputs, fn(string $input) => Result\wrap(fn() => Json\decode($input))); $stats = Result\collect_stats($results); $stats->total(); // total number of results $stats->succeeded(); // number of successes $stats->failed(); // number of failures ``` ## When to Use Result - **Batch processing**: Collect results from multiple operations without stopping at the first error - **Pipeline transformations**: Chain `map()` calls to build data transformation pipelines - **Deferred error handling**: Capture errors as values and handle them at the right level - **API boundaries**: Return Result from service methods instead of throwing across layers See [src/Psl/Result/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/result/src/Psl/Result/) for the full API. --- # Type ## Introduction The `Type` component provides a set of functions to ensure that a given value is of a specific type **at runtime**. It implements the [Parse, Don't Validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) pattern -- turning unstructured input into well-typed, trusted data. ## Usage ```php use Psl\Type; function get_untrusted_input(): mixed { return ''; } // Coerce: convert a string-like value into a non-empty-string $trustedInput = Type\non_empty_string()->coerce(get_untrusted_input()); // Assert: verify it is already a non-empty-string (no conversion) $trustedInput = Type\non_empty_string()->assert(get_untrusted_input()); // Match: check without throwing $isTrustworthy = Type\non_empty_string()->matches(get_untrusted_input()); ``` ## Core Operations Every type provided by this component is an instance of `Type\TypeInterface`, which provides three operations: - **`matches(mixed $value): bool`** -- Checks if the value already satisfies the type. No conversion, no exceptions. - **`assert(mixed $value): Tv`** -- Asserts the value is already of the correct type. Throws `AssertException` if not. No conversion is performed. - **`coerce(mixed $value): Tv`** -- Attempts to convert the value into the target type. Throws `CoercionException` if conversion is impossible. The key distinction is between `assert` (strict -- value must already match) and `coerce` (flexible -- will attempt safe conversions like `"42"` to `42`). ## Static Analysis Your static analyzer fully understands the types provided by this component, but requires a plugin: - **Mago**: enable `psl` plugin in your `mago.toml`: ```toml [analyzer] plugins = ["psl"] ... ``` - **Psalm**: see [php-standard-library/psalm-plugin](https://github.com/php-standard-library/psalm-plugin) - **PHPStan**: see [php-standard-library/phpstan-extension](https://github.com/php-standard-library/phpstan-extension) ## Building Types ### Scalar Types The component provides types for all PHP scalar values: `string()`, `int()`, `float()`, `bool()`, `null()`, and `num()`. Each has well-defined coercion rules: ```php use Psl\Type; // int() coerces from int, numeric strings, and floats with .00 decimal Type\int()->coerce('42'); // 42 Type\int()->coerce(42.0); // 42 // string() coerces from string, int, and Stringable objects Type\string()->coerce(42); // '42' // bool() coerces from bool, 0/1 (int), and '0'/'1' (string) Type\bool()->coerce(1); // true ``` Sized integer types enforce range constraints: `i8()`, `i16()`, `i32()`, `i64()`, `u8()`, `u16()`, `u32()`, `uint()`, `positive_int()`. Float variants: `f32()`, `f64()`. All use the same coercion rules as their base type while guarding the value range. ### Compound Types #### shape -- Structured Arrays `shape()` is the most powerful type for validating complex data structures. It supports nesting, optional fields, and produces detailed error paths: ```php use Psl\Type; $shape = Type\shape([ 'name' => Type\string(), 'articles' => Type\vec(Type\shape([ 'title' => Type\string(), 'content' => Type\string(), 'likes' => Type\int(), 'comments' => Type\optional(Type\vec(Type\shape([ 'user' => Type\string(), 'comment' => Type\string(), ]))), ])), 'pagination' => Type\optional(Type\shape([ 'currentPage' => Type\uint(), 'totalPages' => Type\uint(), 'perPage' => Type\uint(), 'totalRows' => Type\uint(), ])), ]); $untrustedData = [ 'name' => 'Alice', 'articles' => [ [ 'title' => 'Hello World', 'content' => 'My first article.', 'likes' => 5, 'comments' => [ ['user' => 'Bob', 'comment' => 'Great post!'], ], ], ], 'pagination' => [ 'currentPage' => 1, 'totalPages' => 10, 'perPage' => 20, 'totalRows' => 200, ], ]; $validData = $shape->coerce($untrustedData); ``` When validation fails, you get precise error paths: > Expected "array{...}", got "int" **at path "articles.0.comments.0.user"**. #### vec -- Ordered Lists ```php use Psl\Type; $vec = Type\vec(Type\shape([ 'user' => Type\string(), 'comment' => Type\string(), ])); $vec->assert([ ['user' => 'john', 'comment' => 'hello'], ['user' => 'jane', 'comment' => 'world'], ]); ``` Use `non_empty_vec()` to additionally require at least one element. #### dict -- Key-Value Dictionaries ```php use Psl\Type; $dict = Type\dict(Type\string(), Type\shape([ 'title' => Type\string(), 'content' => Type\string(), ])); ``` Use `non_empty_dict()` to require at least one entry. ### Nullability and Optionality - **`nullable(TypeInterface $inner)`** -- Allows `null` alongside the inner type - **`optional(TypeInterface $inner)`** -- Marks a field as optional within a `shape()` (the key may be absent entirely) - **`nullish(TypeInterface $inner)`** -- Combines both: the key may be absent, but defaults to `null` instead of being omitted - **`nonnull()`** -- Accepts any value except `null` ```php use Psl\Type; $shape = Type\shape([ 'name' => Type\string(), 'nickname' => Type\nullable(Type\string()), // present but may be null 'bio' => Type\optional(Type\string()), // key may be missing entirely 'avatar' => Type\nullish(Type\string()), // key may be missing, defaults to null ]); ``` ### Union and Intersection ```php use Psl\Type; interface Loggable { public function log(): string; } interface Exportable { public function export(): string; } // Value must satisfy either type $stringOrInt = Type\union(Type\string(), Type\int()); // Value must satisfy both types $loggableAndExportable = Type\intersection(Type\instance_of(Loggable::class), Type\instance_of(Exportable::class)); ``` Coercion tries each type in order. For unions, the first successful coercion wins. Intersections coerce through one type then assert against the other. ### Object and Enum Types ```php use Psl\Type; enum Status: string { case Active = 'active'; case Inactive = 'inactive'; } enum Color { case Red; case Green; case Blue; } interface Renderable { public function render(): string; } class HtmlRenderer implements Renderable { public function render(): string { return '

Hello

'; } } // Validate class instances $value = new DateTimeImmutable(); Type\instance_of(DateTimeImmutable::class)->assert($value); // Backed enums -- coerce from the backing value Type\backed_enum(Status::class)->coerce('active'); // Unit enums -- only accept enum instances directly Type\unit_enum(Color::class)->assert(Color::Red); // Class strings Type\class_string(Renderable::class)->assert(HtmlRenderer::class); ``` ### Literal Values ```php use Psl\Type; Type\literal_scalar('hello')->assert('hello'); Type\literal_scalar(42)->assert(42); ``` ### Collection Types The component also provides types for PSL collection objects: `vector()`, `mutable_vector()`, `map()`, `mutable_map()`, `set()`, `mutable_set()`. ## Type Conversion with `converted()` The `converted()` type enables custom conversion pipelines. It takes a source type, a target type, and a converter function. This is especially powerful for building reusable type-safe parsers for value objects: ```php use Psl\Type; use Psl\Type\TypeInterface; $dateTimeType = Type\converted( Type\string(), Type\instance_of(DateTimeImmutable::class), static function (string $value): DateTimeImmutable { $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value); if (!$date) { throw new RuntimeException('Invalid date format'); } return $date; }, ); $date = $dateTimeType->coerce('2024-01-15 10:30:00'); // DateTimeImmutable object $dateTimeType->assert($date); // Works -- assert checks the output type (DateTimeImmutable), not the input // Building reusable type-safe parsers for value objects: final class Person { public function __construct( public readonly string $firstName, public readonly string $lastName, ) {} /** @return TypeInterface */ public static function type(): TypeInterface { return Type\converted( Type\shape([ 'firstName' => Type\string(), 'lastName' => Type\string(), ]), Type\instance_of(Person::class), static fn(array $data): Person => new Person($data['firstName'], $data['lastName']), ); } } // Now composable with other types: $shape = Type\shape([ 'person' => Person::type(), 'role' => Type\string(), ]); ``` When conversion fails, error messages indicate which stage failed: > Could not coerce "int" to type "string" **at path "coerce_input(int): string"** > Could not coerce "string" to type "class-string" **at path "coerce_output(string): class-string"** ## JSON Decoding with `json_decoded()` The `json_decoded()` type transparently handles fields that may contain JSON-encoded strings. If the value already matches the inner type, it passes through; if it's a string, it's JSON-decoded and coerced through the inner type. This is especially useful in shapes where database columns store JSON: ```php use Psl\Type; $shape = Type\shape([ 'name' => Type\string(), 'metadata' => Type\json_decoded(Type\shape([ 'role' => Type\string(), 'active' => Type\bool(), ])), ]); // Works with JSON strings (e.g. from a database column) $fromDb = $shape->coerce([ 'name' => 'Alice', 'metadata' => '{"role": "admin", "active": true}', ]); // Also works with already-decoded arrays $fromArray = $shape->coerce([ 'name' => 'Alice', 'metadata' => ['role' => 'admin', 'active' => true], ]); ``` ## Strict Mode with `always_assert()` By default, `coerce()` attempts type conversion. Use `always_assert()` to create a type that rejects any value not already matching, even during coercion: ```php use Psl\Type; use Psl\Type\Exception\CoercionException; $integer = Type\int(); $strictInteger = Type\always_assert(Type\int()); $integer->coerce('1'); // 1 (coerced from string) try { $strictInteger->coerce('1'); // CoercionException! } catch (CoercionException $e) { echo $e->getMessage() . "\n"; } ``` ## Error Paths One of the most valuable features of the Type component is detailed error reporting. When validation fails on nested structures, the error message includes the exact path to the failing value: ```php use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; $type = Type\dict(Type\string(), Type\shape([ 'title' => Type\string(), 'content' => Type\string(), ])); // If key 123 (int) is used instead of a string key: try { $type->assert([ 123 => ['title' => 'Hello', 'content' => 'World'], ]); } catch (AssertException $e) { echo $e->getMessage() . "\n"; } // If 'title' is an array instead of string: try { $type->coerce([ 'foo' => ['title' => ['nested'], 'content' => 'World'], ]); } catch (CoercionException $e) { echo $e->getMessage() . "\n"; } ``` This makes debugging validation failures in deeply nested data structures straightforward. See [src/Psl/Type/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/type/src/Psl/Type/) for the full API. --- # Async The `Async` component brings concurrency into PHP using [cooperative multitasking](https://en.wikipedia.org/wiki/Cooperative_multitasking). > **Note** > > The Async component is built on top of [RevoltPHP](https://github.com/revoltphp/event-loop), which makes it compatible with [Amphp](https://github.com/amphp), > and other libraries that use the same event loop. ## Quick Start ```php use Psl\Async; use Psl\IO; use Psl\Shell; $watcher = Async\Scheduler::onSignal(SIGINT, function (): never { IO\write_error_line('SIGINT received, stopping...'); exit(0); }); Async\Scheduler::unreference($watcher); IO\write_error_line('Press Ctrl+C to stop'); Async\concurrently([ static fn(): string => Shell\execute('sleep', ['3']), static fn(): string => Shell\execute('echo', ['Hello World!']), static fn(): string => Shell\execute('echo', ['Hello World!']), ]); IO\write_error_line('Done!'); ``` ## Core Concepts ### Entry Point `Async\main()` is the entry point for async applications. It executes a closure in the main fiber, then keeps the event loop running until all pending callbacks complete. The closure must return an integer exit code. ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; Async\main(static function (): int { Async\Scheduler::delay(Duration::seconds(1), static function (): void { IO\write_line('hello'); }); return 0; }); // Output: // hello ``` ### Running Async Operations `Async\run()` creates a new fiber and returns an `Awaitable` that resolves to its result: ```php use Psl\Async; use Psl\DateTime\Duration; $awaitable = Async\run(static function (): string { Async\sleep(Duration::seconds(1)); return 'Hello world!'; }); $result = $awaitable->await(); // 'Hello world!' ``` ### Awaitables An `Awaitable` is a promise-like object representing a value that may not yet be available. It can be awaited, mapped, chained, and composed. The `await()` method accepts an optional `CancellationTokenInterface` to cancel the wait. ```php use Psl\Async; use Psl\Str; $awaitable = Async\run(static fn() => 'hello'); // Chain transformations $awaitable = $awaitable->map(static fn($result) => Str\format('%s world', $result)); $result = $awaitable->await(); // 'hello world' ``` Error handling works naturally with `catch()`: ```php use Psl\Async; $awaitable = Async\run(static function (): string { throw new Exception('Something went wrong!'); }); $awaitable = $awaitable->catch(static fn($error) => $error->getMessage()); $result = $awaitable->await(); // 'Something went wrong!' ``` You can also iterate awaitables in completion order, regardless of the order they were started: ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; $handles = [ Async\run(static function () { Async\sleep(Duration::seconds(1)); return 'a'; }), Async\run(static fn() => 'b'), Async\run(static function () { Async\sleep(Duration::milliseconds(300)); return 'c'; }), Async\run(static function () { Async\sleep(Duration::milliseconds(100)); return 'd'; }), ]; foreach (Async\Awaitable::iterate($handles) as $k => $awaitable) { $result = $awaitable->await(); IO\write_line($k . ': ' . $result); } // Output: // 1: b // 3: d // 2: c // 0: a ``` ## Combinators ### concurrently -- Run Tasks in Parallel Runs all closures concurrently and returns their results in the original order: ```php use Psl\Async; use Psl\DateTime\Duration; $results = Async\concurrently([ static function (): string { Async\sleep(Duration::milliseconds(100)); return 'users created'; }, static function (): string { Async\sleep(Duration::milliseconds(50)); return 'organizations created'; }, static function (): string { Async\sleep(Duration::milliseconds(75)); return 'roles created'; }, ]); ``` > **Warning** > > `concurrently(...)` is about kicking-off I/O functions concurrently, not about concurrent execution of code. > If your functions do not use any timers or perform any non-blocking I/O, they will actually be executed in series. Use `Psl\Result\reflect(...)` to continue execution even when individual tasks fail: ```php use Psl\Async; use Psl\Result; use Psl\Shell; [$version, $foo] = Async\concurrently([ Result\reflect(static fn() => Shell\execute('php', ['-v'])), Result\reflect(static fn() => Shell\execute('php', ['-r', 'foo();'])), ]); // $version->isSucceeded() === true // $foo->isFailed() === true ``` ### series -- Run Tasks Sequentially Runs closures one after another. If any throws, execution stops: ```php use Psl\Async; use Psl\DateTime\Duration; $results = Async\series([ static function (): string { Async\sleep(Duration::milliseconds(50)); return 'users created'; }, static function (): string { Async\sleep(Duration::milliseconds(50)); return 'organizations created'; }, static function (): string { Async\sleep(Duration::milliseconds(50)); return 'roles created'; }, static function (): string { Async\sleep(Duration::milliseconds(50)); return 'user organization roles created'; }, ]); ``` ### all -- Await Multiple Awaitables Waits for all `Awaitable`s to complete. If multiple fail, throws `CompositeException`: ```php use Psl\Async; use Psl\Shell; Async\all([ Async\run(static fn() => Shell\execute('echo', ['tests passed'])), Async\run(static fn() => Shell\execute('echo', ['analysis passed'])), Async\run(static fn() => Shell\execute('echo', ['formatting ok'])), ]); ``` ### any / first -- Race Awaitables `any()` returns the first successful result. `first()` returns the first completed result regardless of success or failure: ```php use Psl\Async; // Returns 'hello' -- the first successful result $result = Async\any([ Async\Awaitable::error(new Exception('failed')), Async\Awaitable::complete('hello'), ]); ``` ### sleep / later `Async\sleep()` provides a non-blocking sleep. Multiple concurrent sleeps run in parallel: ```php use Psl\Async; use Psl\DateTime\Duration; $time = time(); Async\concurrently([ static fn() => Async\sleep(Duration::seconds(2)), static fn() => Async\sleep(Duration::seconds(2)), static fn() => Async\sleep(Duration::seconds(2)), ]); // Total time: ~2 seconds, not 6 ``` `sleep()` also accepts a `CancellationTokenInterface` to wake early, which is useful for interruptible retry delays: ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; $token = new Async\SignalCancellationToken(); // Cancel the sleep after 50ms Async\Scheduler::delay(Duration::milliseconds(50), static fn(string $_) => $token->cancel()); try { // Sleep for 5 seconds, but wake early if the token is cancelled Async\sleep(Duration::seconds(5), $token); } catch (Async\Exception\CancelledException) { IO\write_line('Woke early!'); } ``` `Async\later()` reschedules the current fiber, allowing other pending callbacks to execute. ## Concurrency Control ### Semaphore Limits the number of concurrent operations. All operations use the same processing function: ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; $semaphore = new Async\Semaphore(2, static function (int $input): void { IO\write_error_line('> started : %d', $input); Async\sleep(Duration::seconds(1)); IO\write_error_line('> finished: %d', $input); }); Async\concurrently([ fn() => $semaphore->waitFor(1), fn() => $semaphore->waitFor(2), fn() => $semaphore->waitFor(3), ]); // Output: // > started: 1 // > started: 2 // > finished: 1 // > started: 3 // > finished: 2 // > finished: 3 $semaphore->cancel(new Exception('shutting down')); ``` The semaphore provides methods to inspect state (`getPendingOperations()`, `getOngoingOperations()`, `hasPendingOperations()`) and to cancel pending work. Both `waitFor()` and `waitForPending()` accept an optional `CancellationTokenInterface`. ### KeyedSemaphore Like `Semaphore`, but applies concurrency limits per key. This is useful when you want to limit concurrent access to individual resources: ```php use Psl\Async; use Psl\DateTime\Duration; $semaphore = new Async\KeyedSemaphore(2, static function (string $_key, int $_input): void { Async\sleep(Duration::seconds(1)); }); Async\concurrently([ fn() => $semaphore->waitFor('foo', 1), // starts immediately fn() => $semaphore->waitFor('foo', 2), // starts immediately (limit 2 for 'foo') fn() => $semaphore->waitFor('foo', 3), // waits for one 'foo' to finish fn() => $semaphore->waitFor('bar', 1), // starts immediately (separate key) ]); ``` Both `waitFor()` and `waitForPending()` accept an optional `CancellationTokenInterface`. ### Sequence A specialized semaphore with a concurrency limit of 1 -- operations run one at a time: ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; $sequence = new Async\Sequence(static function (int $input): void { IO\write_error_line('> started : %d', $input); Async\sleep(Duration::seconds(1)); IO\write_error_line('> finished: %d', $input); }); Async\concurrently([ fn() => $sequence->waitFor(1), fn() => $sequence->waitFor(2), fn() => $sequence->waitFor(3), ]); // Output: // > started: 1 // > finished: 1 // > started: 2 // > finished: 2 // > started: 3 // > finished: 3 ``` Both `waitFor()` and `waitForPending()` accept an optional `CancellationTokenInterface`. ### KeyedSequence Like `Sequence`, but applies the sequential constraint per key. Different keys can run concurrently while the same key is serialized: ```php use Psl\Async; use Psl\DateTime\Duration; $sequence = new Async\KeyedSequence(static function (string $_key, int $_input): void { Async\sleep(Duration::seconds(1)); }); Async\concurrently([ fn() => $sequence->waitFor('foo', 1), // starts immediately fn() => $sequence->waitFor('foo', 2), // waits for foo:1 fn() => $sequence->waitFor('bar', 1), // starts immediately (different key) ]); ``` Both `waitFor()` and `waitForPending()` accept an optional `CancellationTokenInterface`. ### TaskGroup `TaskGroup` lets you defer multiple closures for concurrent execution and await them all at once. If any task throws, the exception is propagated after all tasks finish. If multiple tasks throw, a `CompositeException` is raised: ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; $results = []; $group = new Async\TaskGroup(); $group->defer(static function () use (&$results): void { Async\sleep(Duration::milliseconds(20)); $results[] = 'slow'; }); $group->defer(static function () use (&$results): void { $results[] = 'fast'; }); $group->awaitAll(); IO\write_line('Order: %s', Psl\Str\join($results, ', ')); // "fast, slow" ``` `awaitAll()` accepts an optional `CancellationTokenInterface`. The task list is cleared after each `awaitAll()` call, so the group is reusable. ### WaitGroup `WaitGroup` is a counter-based synchronization primitive inspired by Go's `sync.WaitGroup`. Call `add()` before starting work, `done()` when work completes, and `wait()` to block until the counter reaches zero: ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; $wg = new Async\WaitGroup(); for ($i = 0; $i < 3; $i++) { $wg->add(); Async\run(static function () use ($wg, $i): void { Async\sleep(Duration::milliseconds(10 * ($i + 1))); IO\write_line('Task %d done', $i); $wg->done(); })->ignore(); } $wg->wait(); IO\write_line('All tasks done'); ``` `wait()` accepts an optional `CancellationTokenInterface`. Multiple fibers can wait on the same `WaitGroup` concurrently. ## Deferred > **Warning** > > The `Deferred` API is an advanced API that many applications probably don't need. > Use `run(...)` and other combinators when possible. `Deferred` is the low-level primitive for resolving future values. It produces an `Awaitable` that is completed manually: ```php use Psl\Async; use Psl\DateTime\Duration; /** * @return Async\Awaitable<'hello'> */ function get_message(): Async\Awaitable { /** @var Async\Deferred<'hello'> $deferred */ $deferred = new Async\Deferred(); // Complete the deferred with 'hello' after 2 seconds. Async\Scheduler::delay(Duration::seconds(2), static fn() => $deferred->complete('hello')); return $deferred->getAwaitable(); } get_message()->await(); // 'hello' ``` The `Deferred` and `Awaitable` are intentionally separated: always return `$deferred->getAwaitable()` to API consumers. If you're passing `Deferred` objects around, you're probably doing something wrong. ## Scheduler The `Scheduler` is a wrapper around the Revolt event loop. It provides static methods for registering callbacks: - `Scheduler::defer($callback)` -- execute on next tick - `Scheduler::delay($duration, $callback)` -- execute after a delay - `Scheduler::repeat($interval, $callback)` -- execute repeatedly - `Scheduler::onSignal($signal, $callback)` -- execute on OS signal - `Scheduler::onReadable($stream, $callback)` -- execute when stream is readable - `Scheduler::onWritable($stream, $callback)` -- execute when stream is writable - `Scheduler::queue($callback)` -- queue a microtask All registration methods return a string identifier that can be used with `cancel()`, `enable()`, `disable()`, `reference()`, and `unreference()`. See [revolt.run](https://revolt.run/) for more information on the underlying event loop. ## Cancellation Cancellation tokens allow you to cancel in-flight async operations from external code. Every suspension point in PSL (`await()`, `read()`, `write()`, `waitFor()`, `connect()`, etc.) accepts an optional `CancellationTokenInterface`. ### CancellationTokenInterface The base interface. Implementations provide: - `subscribe(Closure $callback): string` -- register a callback invoked on cancellation - `unsubscribe(string $id): void` -- remove a callback - `isCancelled(): bool` -- check cancellation state - `throwIfCancelled(): void` -- throw `CancelledException` if cancelled ### SignalCancellationToken Manually triggered. Call `cancel()` from any fiber to cancel all subscribed operations: ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; $token = new Async\SignalCancellationToken(); // Simulate cancelling after 50ms Async\Scheduler::delay(Duration::milliseconds(50), static fn() => $token->cancel( new RuntimeException('Client disconnected'), )); $deferred = new Async\Deferred(); try { // This will be cancelled before the deferred completes $deferred->getAwaitable()->await($token); } catch (Async\Exception\CancelledException $e) { IO\write_line('Cancelled: %s', $e->getPrevious()?->getMessage() ?? 'no cause'); IO\write_line('Token type: %s', $e->getToken()::class); } ``` ### TimeoutCancellationToken Auto-cancels after a duration. Replaces the old `Duration $timeout` pattern: ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; // TimeoutCancellationToken auto-cancels after the given duration $token = new Async\TimeoutCancellationToken(Duration::milliseconds(50)); $deferred = new Async\Deferred(); // Keep the loop alive Async\run(static function () use ($deferred): void { Async\sleep(Duration::seconds(5)); $deferred->complete('too late'); })->ignore(); try { $deferred->getAwaitable()->await($token); } catch (Async\Exception\CancelledException $e) { $cause = $e->getPrevious(); IO\write_line('Timed out: %s', $cause !== null ? $cause::class : 'unknown'); // Psl\Async\Exception\TimeoutException } ``` ### LinkedCancellationToken Combines two tokens, cancelled when either fires. This is useful for layering a request-scoped token with an operation-specific timeout: ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; // Simulate a request-scoped token that cancels when the client disconnects $requestToken = new Async\SignalCancellationToken(); // Combine with a per-operation timeout $linked = new Async\LinkedCancellationToken( $requestToken, new Async\TimeoutCancellationToken(Duration::milliseconds(50)), ); $deferred = new Async\Deferred(); Async\run(static function () use ($deferred): void { Async\sleep(Duration::seconds(5)); $deferred->complete('too late'); })->ignore(); try { // Cancelled by whichever fires first $deferred->getAwaitable()->await($linked); } catch (Async\Exception\CancelledException $e) { $cause = $e->getToken(); IO\write_line('Cancelled by: %s', $cause::class); } ``` The `CancelledException` thrown by a linked token is forwarded directly from the inner token that fired -- `getToken()` returns the actual token that triggered cancellation (e.g., the `TimeoutCancellationToken` or `SignalCancellationToken`), not the linked wrapper. ### NullCancellationToken A no-op token that is never cancelled. Used as the default parameter value -- you never need to construct it explicitly. ### Cancelling Semaphore/Sequence Waits All concurrency primitives accept cancellation tokens. If a token fires while waiting for a slot, the wait is cancelled without affecting other pending operations: ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; $semaphore = new Async\Semaphore(1, static function (string $task): string { Async\sleep(Duration::milliseconds(100)); return $task . ' done'; }); // First task starts immediately Async\run(static function () use ($semaphore): void { $result = $semaphore->waitFor('task-1'); IO\write_line($result); })->ignore(); // Second task must wait - but we cancel it after 50ms $token = new Async\TimeoutCancellationToken(Duration::milliseconds(50)); try { $semaphore->waitFor('task-2', $token); } catch (Async\Exception\CancelledException) { IO\write_line('task-2 cancelled while waiting for semaphore slot'); } ``` ### CancelledException When a token fires, `CancelledException` is thrown. It carries: - `getPrevious()` -- the cause (e.g., `TimeoutException` for timeout tokens, or a custom exception passed to `SignalCancellationToken::cancel()`) - `getToken()` -- the token that triggered the cancellation ## Error Handling - **`CancelledException`** -- thrown when a cancellation token is triggered. Use `$e->getToken()` to identify the source and `$e->getPrevious()` for the cause. - **`CompositeException`** -- wraps multiple exceptions when several concurrent operations fail. Use `$e->getReasons()` to get all underlying exceptions. - **`TimeoutException`** -- used internally as the cause inside `CancelledException` when a `TimeoutCancellationToken` fires. - **`UnhandledAwaitableException`** -- thrown by the scheduler when a failed `Awaitable` is never awaited or handled. Use `$awaitable->ignore()` to suppress this. See [src/Psl/Async/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/async/src/Psl/Async/) for the full API. --- # Channel The `Channel` component provides message-passing channels for async communication, inspired by Go's channels and Rust's `std::sync::mpsc`. A channel splits into a `SenderInterface` and a `ReceiverInterface`, giving you a clear separation between the producing and consuming sides. Channels come in two flavors: - **Bounded** -- Has a fixed capacity. Sending blocks when the channel is full. - **Unbounded** -- Grows without limit. Sending never blocks due to capacity. ## Usage ### Creating Channels Both factory functions return a `[ReceiverInterface, SenderInterface]` pair: ```php use Psl\Channel; // Bounded: holds at most 10 messages [$receiver, $sender] = Channel\bounded(10); // Unbounded: no capacity limit [$receiver, $sender] = Channel\unbounded(); ``` ### Sending and Receiving ```php use Psl\Channel; [$receiver, $sender] = Channel\bounded(3); $sender->send('hello'); $sender->send('world'); $receiver->receive(); // 'hello' $receiver->receive(); // 'world' ``` `send()` waits for space if the channel is full. `receive()` waits for a message if the channel is empty. For non-blocking alternatives, use `trySend()` and `tryReceive()`, which throw immediately instead of waiting: ```php use Psl\Channel; use Psl\IO; [$receiver, $sender] = Channel\bounded(1); $sender->trySend('first'); try { $sender->trySend('second'); // throws FullChannelException -- channel is at capacity } catch (Channel\Exception\FullChannelException) { IO\write_line('Channel is full, cannot send "second"'); } $receiver->tryReceive(); // 'first' try { $receiver->tryReceive(); // throws EmptyChannelException -- nothing left } catch (Channel\Exception\EmptyChannelException) { IO\write_line('Channel is empty, cannot receive'); } ``` ### Closing Channels Closing a channel signals that no more messages will be sent. Messages already in the channel can still be received: ```php use Psl\Channel; use Psl\IO; [$receiver, $sender] = Channel\bounded(10); $sender->send('last message'); $sender->close(); $sender->isClosed(); // true $receiver->receive(); // 'last message' try { $receiver->receive(); // throws ClosedChannelException -- no more messages } catch (Channel\Exception\ClosedChannelException) { IO\write_line('Channel is closed, cannot receive more messages'); } ``` ### Inspecting Channel State ```php use Psl\Channel; [$receiver, $sender] = Channel\bounded(5); $sender->getCapacity(); // 5 $sender->count(); // 0 (number of messages currently in the channel) $sender->isEmpty(); // true $sender->isFull(); // false $sender->isClosed(); // false ``` For unbounded channels, `getCapacity()` returns `null` and `isFull()` always returns `false`. ### Producer-Consumer Pattern ```php use Psl\Async; use Psl\Channel; use Psl\IO; /** * @var Channel\ReceiverInterface $receiver * @var Channel\SenderInterface $sender */ [$receiver, $sender] = Channel\bounded(100); // Producer Async\run(function () use ($sender): void { foreach (['job-1', 'job-2', 'job-3'] as $job) { $sender->send($job); } $sender->close(); }); // Consumer Async\run(function () use ($receiver): void { while (!$receiver->isClosed() || !$receiver->isEmpty()) { $job = $receiver->receive(); IO\write_error_line('Processing: %s', $job); } }); Async\later(); ``` ### Fan-Out: Multiple Consumers Distribute work across several consumers by sharing the receiver: ```php use Psl\Async; use Psl\Channel; use Psl\IO; /** * @var Channel\ReceiverInterface $receiver * @var Channel\SenderInterface $sender */ [$receiver, $sender] = Channel\bounded(100); // Spawn 3 worker consumers for ($i = 0; $i < 3; $i++) { Async\run(function () use ($receiver, $i): void { while (!$receiver->isClosed() || !$receiver->isEmpty()) { $task = $receiver->receive(); IO\write_error_line('Worker %d processing: %s', $i, $task); } }); } // Feed tasks $tasks = ['task-a', 'task-b', 'task-c', 'task-d', 'task-e']; foreach ($tasks as $task) { $sender->send($task); } $sender->close(); Async\later(); ``` ### Cancellation Both `send()` and `receive()` accept an optional `CancellationTokenInterface`. This is useful when you don't want to wait indefinitely for a message or for space in a bounded channel: ```php use Psl\Async; use Psl\Channel; use Psl\DateTime\Duration; use Psl\IO; /** @var Channel\ReceiverInterface $receiver */ /** @var Channel\SenderInterface $sender */ [$receiver, $sender] = Channel\bounded(1); // Producer sends one message then stops Async\run(static function () use ($sender): void { $sender->send('hello'); Async\sleep(Duration::seconds(5)); $sender->send('never arrives'); })->ignore(); // Consumer receives with a timeout $token = new Async\TimeoutCancellationToken(Duration::milliseconds(50)); $first = $receiver->receive($token); IO\write_line('Received: %s', $first); try { // This will be cancelled - no second message within 50ms $receiver->receive($token); } catch (Async\Exception\CancelledException) { IO\write_line('Timed out waiting for next message'); } ``` `trySend()` and `tryReceive()` are non-blocking and don't need cancellation tokens -- they throw immediately if the channel is full or empty. ## When to Use Channel - **Decoupling producers and consumers** -- The sender doesn't need to know who processes the messages - **Backpressure** -- Bounded channels naturally slow down fast producers when consumers can't keep up - **Async coordination** -- Pass data between concurrent tasks without shared mutable state See [src/Psl/Channel/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/channel/src/Psl/Channel/) for the full API. --- # Promise The `Promise` component provides a promise interface for deferred computations. A promise represents a value that will be available in the future -- either resolved with a success value or rejected with an exception. You attach callbacks to react to the outcome without blocking. ## Design `PromiseInterface` defines four methods for composing asynchronous operations: - **`then()`** -- Handle both success and failure in one call - **`map()`** -- Transform the resolved value - **`catch()`** -- Recover from rejection - **`always()`** -- Run cleanup logic regardless of outcome Every method returns a new `PromiseInterface`, so you can chain them into pipelines. ## Usage ### Transforming a Resolved Value `map()` applies a function to the resolved value. If the promise is rejected, the callback is skipped and the rejection propagates: ```php use Psl\Async; use Psl\Str; /** @var Async\Awaitable $promise */ $promise = Async\run(static fn() => 'hello'); $upper = $promise->map(fn(string $body) => Str\uppercase($body)); // If $promise resolves with 'hello', $upper resolves with 'HELLO' // If $promise is rejected, $upper is also rejected with the same exception $upper->await(); // 'HELLO' ``` ### Recovering from Failure `catch()` attaches a callback that is only invoked when the promise is rejected. The returned value becomes the new resolved value: ```php use Psl\Async; /** @var Async\Awaitable $promise */ $promise = Async\run(static fn() => throw new Exception('oh no')); $safe = $promise->catch(fn(Throwable $_) => 'default response'); // If $promise is rejected, $safe resolves with 'default response' // If $promise succeeds, $safe resolves with the original value $safe->await(); // 'default response' ``` ### Handling Both Cases with then() `then()` is a shortcut for chaining `map()` and `catch()`: ```php use Psl\Async; /** @var Async\Awaitable $promise */ $promise = Async\run(static fn() => 'hello world'); $result = $promise->then(fn(string $value) => ['value' => $value], fn(Throwable $e) => ['error' => $e->getMessage()]); $result->await(); // ['value' => 'hello world'] ``` This is equivalent to: ```php use Psl\Async; /** @var Async\Awaitable $promise */ $promise = Async\run(static fn() => 'hello world'); $result = $promise->map(fn(string $value) => ['value' => $value])->catch(fn(Throwable $e) => [ 'error' => $e->getMessage(), ]); $result->await(); // ['value' => 'hello world'] ``` ### Cleanup with always() `always()` runs a callback when the promise settles, regardless of whether it resolved or was rejected. The promise's value passes through unchanged (unless the callback throws): ```php use Psl\Async; use Psl\IO; use Psl\Str; /** @var Async\Awaitable $promise */ $promise = Async\run(static fn() => 'hello'); $promise ->map(fn(string $content) => Str\uppercase($content)) ->always(fn() => IO\write_error_line('cleanup complete')) ->await(); ``` ### Building Pipelines Because every method returns a new promise, you can compose multi-step async workflows: ```php use Psl\Async; use Psl\IO; use Psl\Json; use Psl\Type; /** @var Async\Awaitable $promise */ $promise = Async\run(static fn() => '{"name": "psl", "version": "3.0"}'); $processed = $promise ->map(fn(string $raw) => Json\decode($raw)) ->map( fn(mixed $data) => Type\shape([ 'name' => Type\string(), 'version' => Type\string(), ])->coerce($data), ) ->map(fn(array $valid) => ['project' => $valid['name'], 'v' => $valid['version']]) ->catch(fn(Throwable $e) => ['error' => $e->getMessage()]) ->always(fn() => IO\write_error_line('pipeline complete')); $processed->await(); ``` ## When to Use Promise - **Async operations** -- React to the outcome of concurrent tasks - **Pipeline composition** -- Chain transformations that may fail at any step - **Error boundaries** -- Catch and recover from failures at specific points in the chain - **Resource cleanup** -- Use `always()` to guarantee cleanup runs See [src/Psl/Promise/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/promise/src/Psl/Promise/) for the full API. --- # Collection The `Collection` component provides generic, object-oriented collection types as an alternative to PHP arrays. Collections are strongly typed, support generics via doc-block annotations, and offer a rich API for filtering, mapping, and slicing. Each collection type comes in two variants: an immutable (`readonly`) version and a mutable version. | Type | Immutable | Mutable | |------|-----------|---------| | Ordered list | `Vector` | `MutableVector` | | Key-value map | `Map` | `MutableMap` | | Unique values | `Set` | `MutableSet` | ## Vector A `Vector` is an ordered, integer-indexed sequence of values -- similar to a PHP `list`. ```php use Psl\Collection\MutableVector; use Psl\Collection\Vector; use Psl\Str; // Immutable vector $names = Vector::fromArray(['Alice', 'Bob', 'Charlie']); $names->count(); // 3 $names->at(0); // 'Alice' $names->first(); // 'Alice' $names->last(); // 'Charlie' $names->toArray(); // ['Alice', 'Bob', 'Charlie'] // Filtering and mapping return new immutable vectors $short = $names->filter(fn(string $n): bool => Str\length($n) <= 3); $upper = $names->map(Str\uppercase(...)); /** * Mutable vector -- supports in-place modification * * @var MutableVector $tasks */ $tasks = MutableVector::fromArray(['eat', 'sleep']); $tasks->add('code'); $tasks->remove(0); // removes 'eat', re-indexes $tasks->toArray(); // ['sleep', 'code'] ``` ## Map A `Map` is an ordered collection of key-value pairs. Keys must be `int` or `string`. ```php use Psl\Collection\Map; use Psl\Collection\MutableMap; /** * Immutable map * * @var Map $scores */ $scores = Map::fromArray(['alice' => 95, 'bob' => 82, 'charlie' => 91]); $scores->at('alice'); // 95 $scores->get('unknown'); // null (no exception) $scores->contains('bob'); // true $scores->keys(); // Vector $scores->values(); // Vector // Filter to passing scores $passing = $scores->filter(fn(int $s): bool => $s >= 90); $passing->toArray(); // ['alice' => 95, 'charlie' => 91] /** * Mutable map * * @var MutableMap $config */ $config = MutableMap::fromArray(['debug' => 'false']); $config->add('version', '2.0'); // adds or overwrites $config->set('debug', 'true'); // overwrites existing key (throws if missing) $config->remove('debug'); ``` ## Set A `Set` stores unique values with no duplicate entries. Values must be `int` or `string` (array-key types). ```php use Psl\Collection\MutableSet; use Psl\Collection\Set; /** * A set is a collection of unique values. It can be either mutable or immutable. * * @var Set $tags */ $tags = Set::fromArray(['php', 'psl', 'php']); // duplicates removed $tags->count(); // 2 $tags->contains('php'); // true $tags->contains('rust'); // false /** * Mutable set -- supports in-place modification * * @var MutableSet $visited */ $visited = MutableSet::fromArray([]); $visited->add('page-a'); $visited->add('page-b'); $visited->add('page-a'); // no-op, already present $visited->count(); // 2 $visited->remove('page-a'); ``` ## Common Operations All collection types share a consistent API for reading, transforming, and slicing: ```php use Psl\Collection\Vector; $v = Vector::fromArray([10, 20, 30]); $v->at(1); // 20 (throws OutOfBoundsException if missing) $v->get(1); // 20 (returns null if missing) $v->linearSearch(20); // 1 (index of the value) // Slicing operations return new collections $v = Vector::fromArray([1, 2, 3, 4, 5]); $v->take(3)->toArray(); // [1, 2, 3] $v->drop(2)->toArray(); // [3, 4, 5] $v->slice(1, 3)->toArray(); // [2, 3, 4] $v->chunk(2); // Vector> of [[1,2], [3,4], [5]] ``` All collections implement `IteratorAggregate` for use in `foreach`, and `JsonSerializable` for JSON encoding. Maps always serialize as JSON objects (`{}`), vectors and sets as JSON arrays (`[]`). Mutable collections also implement `ArrayAccess`: ```php use Psl\Collection\MutableVector; /** * @var MutableVector $v */ $v = MutableVector::fromArray([10, 20, 30]); $v[] = 40; // appends $v[1] = 99; // sets index 1 unset($v[0]); // removes index 0 ``` See [src/Psl/Collection/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/collection/src/Psl/Collection/) for the full API. --- # DataStructure The `DataStructure` component provides three classic data structures: `Queue` (FIFO), `Stack` (LIFO), and `PriorityQueue`. They are generic, type-safe, and offer a consistent API with both safe and throwing access methods. ## Queue (FIFO) A queue processes items in first-in, first-out order. Items are added to the back and removed from the front: ```php use Psl\DataStructure; $queue = new DataStructure\Queue(); $queue->enqueue('first'); $queue->enqueue('second'); $queue->enqueue('third'); $queue->count(); // 3 $queue->peek(); // "first" (does not remove) $queue->pull(); // "first" (removes and returns) $queue->dequeue(); // "second" (removes and returns, throws if empty) ``` Use `pull()` when an empty queue is a normal condition (it returns `null`). Use `dequeue()` when an empty queue is a bug (it throws `UnderflowException`). ## Stack (LIFO) A stack processes items in last-in, first-out order. Items are added to and removed from the top: ```php use Psl\DataStructure; $stack = new DataStructure\Stack(); $stack->push('first'); $stack->push('second'); $stack->push('third'); $stack->count(); // 3 $stack->peek(); // "third" (does not remove) $stack->pull(); // "third" (removes and returns) $stack->pop(); // "second" (removes and returns, throws if empty) ``` Use `pull()` for safe access (returns `null` if empty) and `pop()` when emptiness is unexpected (throws `UnderflowException`). ## PriorityQueue A priority queue dequeues items with the highest priority first. Items with equal priority are dequeued in FIFO order: ```php use Psl\DataStructure; $pq = new DataStructure\PriorityQueue(); $pq->enqueue('low-priority task', priority: 1); $pq->enqueue('high-priority task', priority: 10); $pq->enqueue('medium-priority task', priority: 5); $pq->enqueue('another high-priority task', priority: 10); $pq->dequeue(); // "high-priority task" (priority 10, enqueued first) $pq->dequeue(); // "another high-priority task" (priority 10, enqueued second) $pq->dequeue(); // "medium-priority task" (priority 5) $pq->dequeue(); // "low-priority task" (priority 1) ``` If no priority is specified, items default to priority `0`. ## Common API All three structures implement `Countable` and share a consistent pattern: | Method | Queue | Stack | PriorityQueue | |--------|-------|-------|---------------| | Add item | `enqueue($item)` | `push($item)` | `enqueue($item, $priority)` | | Peek at next | `peek()` | `peek()` | `peek()` | | Remove (safe) | `pull()` | `pull()` | `pull()` | | Remove (throws) | `dequeue()` | `pop()` | `dequeue()` | | Count items | `count()` | `count()` | `count()` | - `peek()` returns the next item without removing it, or `null` if empty - `pull()` removes and returns the next item, or `null` if empty - `dequeue()` / `pop()` removes and returns the next item, or throws `UnderflowException` if empty See [src/Psl/DataStructure/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/data-structure/src/Psl/DataStructure/) for the full API. --- # Graph The `Graph` component provides immutable graph data structures and algorithms for working with directed and undirected graphs. Graphs are fundamental data structures for modeling relationships and networks. This component supports directed and undirected graphs, weighted and unweighted edges, traversal (BFS, DFS), shortest path finding, topological sorting, and cycle detection. All operations are immutable and return new graph instances. ## Design The Graph component uses an adjacency list representation: - **`DirectedGraph`**: Directed graph with edges from node A to node B - **`UndirectedGraph`**: Undirected graph with bidirectional edges - **`Edge`**: Represents an edge with optional weight - **Flexible node types**: Nodes can be any type (objects, arrays, scalars, resources, etc.) ## Usage ### Building Graphs ```php use Psl\Graph; // Directed graph $graph = Graph\directed(); $graph = Graph\add_node($graph, 'A'); $graph = Graph\add_edge($graph, 'A', 'B'); $graph = Graph\add_edge($graph, 'B', 'C'); // Undirected graph (edges go in both directions) $graph = Graph\undirected(); $graph = Graph\add_edge($graph, 'A', 'B'); // adds edges in both directions // Weighted edges $graph = Graph\directed(); $graph = Graph\add_edge($graph, 'A', 'B', 5); $graph = Graph\add_edge($graph, 'A', 'C', 10); $graph = Graph\add_edge($graph, 'B', 'C', 2); ``` ### Traversal ```php use Psl\Graph; $graph = Graph\directed(); $graph = Graph\add_edge($graph, 'A', 'B'); $graph = Graph\add_edge($graph, 'A', 'C'); $graph = Graph\add_edge($graph, 'B', 'D'); Graph\bfs($graph, 'A'); // ['A', 'B', 'C', 'D'] -- breadth-first Graph\dfs($graph, 'A'); // ['A', 'B', 'D', 'C'] -- depth-first ``` ### Shortest Path For unweighted graphs, BFS is used. For weighted graphs, Dijkstra's algorithm is used. ```php use Psl\Graph; /** @var Graph\DirectedGraph $graph */ $graph = Graph\directed(); $graph = Graph\add_edge($graph, 'NYC', 'Boston', 215); $graph = Graph\add_edge($graph, 'NYC', 'Philadelphia', 95); $graph = Graph\add_edge($graph, 'Philadelphia', 'Boston', 310); $path = Graph\shortest_path($graph, 'NYC', 'Boston'); // ['NYC', 'Boston'] (cost: 215, shorter than via Philadelphia) /** * For non-integer weights, use shortest_path_by with a converter * * @var Graph\DirectedGraph $graph */ $graph = Graph\directed(); $graph = Graph\add_edge($graph, 'A', 'B', 1.5); $graph = Graph\add_edge($graph, 'B', 'C', 2.3); $path = Graph\shortest_path_by($graph, 'A', 'C', fn(float $w): int => (int) ($w * 1000)); ``` ### Cycle Detection and Topological Sort ```php use Psl\Graph; // Topological sort for DAGs (dependency ordering) $graph = Graph\directed(); $graph = Graph\add_edge($graph, 'libc', 'gcc'); $graph = Graph\add_edge($graph, 'gcc', 'app'); $graph = Graph\add_edge($graph, 'libc', 'app'); $buildOrder = Graph\topological_sort($graph); // ['libc', 'gcc', 'app'] // Cycle detection $graph = Graph\directed(); $graph = Graph\add_edge($graph, 'A', 'B'); $graph = Graph\add_edge($graph, 'B', 'C'); $graph = Graph\add_edge($graph, 'C', 'A'); Graph\has_cycle($graph); // true ``` ### Object Nodes Nodes can be any type, including objects: ```php use Psl\Graph; class Task { public function __construct( public readonly string $name, public readonly int $duration, ) {} } $graph = Graph\directed(); $compile = new Task('compile', 5); $test = new Task('test', 3); $deploy = new Task('deploy', 2); $graph = Graph\add_edge($graph, $compile, $test); $graph = Graph\add_edge($graph, $test, $deploy); $executionOrder = Graph\topological_sort($graph); // [$compile, $test, $deploy] ``` ## Use Cases - **Dependency Resolution**: Topological sort for build systems, package managers - **Route Finding**: Shortest path for navigation, network routing - **Social Networks**: Friend relationships, recommendations - **State Machines**: Transitions between states - **Data Flow**: Pipeline dependencies, task scheduling See [src/Psl/Graph/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/graph/src/Psl/Graph/) for the full API. --- # Range The `Range` component provides a way to create a range of integer values. Ranges represent sets of integer values with optional lower and upper bounds. They support containment checks, iteration, and bound manipulation. ## Usage ### Creating Ranges There are four types of ranges, created with constructor functions: ```php use Psl\Range; $range = Range\from(0); // 0.. (from 0 to infinity) $range = Range\to(10); // ..10 (up to 10, exclusive) $range = Range\to(10, true); // ..=10 (up to 10, inclusive) $range = Range\between(0, 10); // 0..10 (from 0 to 10, exclusive) $range = Range\between(0, 10, true); // 0..=10 (from 0 to 10, inclusive) $range = Range\full(); // .. (all integers) ``` ### Containment Checks All range types support `contains()`: ```php use Psl\Range; $range = Range\between(0, 10); $range->contains(5); // true $range->contains(100); // false $range = Range\from(0); $range->contains(5); // true $range->contains(100); // true $range = Range\full(); $range->contains(5); // true $range->contains(100); // true ``` ### Iterating Over Ranges Ranges with a lower bound (`FromRange` and `BetweenRange`) are iterable: ```php use Psl\Range; $range = Range\between(0, 5); $range = Range\between(0, 5, true); // FromRange is iterable but infinite -- use with care $range = Range\from(0); foreach ($range as $value) { // $value is 0, 1, 2, 3, ... if ($value > 100) { break; } } ``` ### Manipulating Bounds Ranges can be combined with new bounds to create different range types: ```php use Psl\Range; // Add an upper bound to a FromRange $from = Range\from(0); $between = $from->withUpperBoundInclusive(10); // Now a BetweenRange: 0..=10 // Remove the lower bound $to = $between->withoutLowerBound(); // Now a ToRange: ..=10 // Add a lower bound to a ToRange $between = $to->withLowerBound(5); // Now a BetweenRange: 5..=10 ``` See [src/Psl/Range/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/range/src/Psl/Range/) for the full API. --- # Tree The `Tree` component provides immutable tree data structures and operations for hierarchical data manipulation. Trees are fundamental data structures for representing hierarchical relationships. This component provides immutable tree nodes (`LeafNode` and `TreeNode`), pure constructor functions, functional operations (map, filter, reduce, fold), tree traversal algorithms, and search utilities. ## Design The Tree component has a simple design: - **`NodeInterface`**: Base interface with `getValue(): T` - **`LeafNode`**: Leaf nodes (no children) - **`TreeNode`**: Tree nodes with `getChildren(): list>` All tree operations are immutable -- they return new trees rather than modifying existing ones. ## Usage ### Building Trees Use the `tree()` and `leaf()` constructor functions to build trees: ```php use Psl\Tree; $tree = Tree\tree('root', [ Tree\leaf('child1'), Tree\leaf('child2'), Tree\tree('child3', [ Tree\leaf('grandchild1'), ]), ]); ``` You can also build trees from nested arrays or flat lists with parent references, which is especially useful for database records: ```php use Psl\Tree; // From a nested array $tree = Tree\from_array([ 'value' => 'root', 'children' => [ ['value' => 'child1', 'children' => []], ['value' => 'child2', 'children' => []], ], ]); // From database records with parent_id relationships $records = [ ['id' => 1, 'name' => 'Root', 'parent_id' => null], ['id' => 2, 'name' => 'Child A', 'parent_id' => 1], ['id' => 3, 'name' => 'Child B', 'parent_id' => 1], ['id' => 4, 'name' => 'Grandchild', 'parent_id' => 2], ]; $tree = Tree\from_list( $records, fn($record) => $record['id'], fn($record) => $record['parent_id'], fn($record) => $record['name'], ); ``` ### Transforming Trees ```php use Psl\Math; use Psl\Tree; $tree = Tree\tree(1, [Tree\leaf(2), Tree\leaf(3)]); // Map: apply a function to every node value $doubled = Tree\map($tree, fn(int $x): int => $x * 2); // Result: tree(2, [leaf(4), leaf(6)]) // Filter: keep only nodes matching a predicate $filtered = Tree\filter($tree, fn(int $x): bool => $x >= 2); // Result: tree(2, [leaf(3)]) -- root must match or null is returned // Reduce: collapse the tree to a single value (pre-order) $sum = Tree\reduce($tree, fn(int $acc, int $x): int => $acc + $x, 0); // Result: 6 // Fold: post-order fold with access to children results $result = Tree\fold( $tree, /** * @param int $value * @param list $children * @return int */ fn(int $value, array $children): int => $value + Math\sum($children), ); // Result: 6 ``` ### Traversal Three traversal orders are available: ```php use Psl\Tree; $tree = Tree\tree(1, [Tree\tree(2, [Tree\leaf(3)]), Tree\leaf(4)]); Tree\pre_order($tree); // [1, 2, 3, 4] -- root first, then children Tree\post_order($tree); // [3, 2, 4, 1] -- children first, then root Tree\level_order($tree); // [1, 2, 4, 3] -- breadth-first ``` ### Searching and Paths ```php use Psl\Str; use Psl\Tree; $tree = Tree\tree('a', [ Tree\tree('b', [Tree\leaf('c')]), Tree\leaf('d'), ]); // Find the path from root to a matching node $path = Tree\path_to($tree, fn($x) => $x === 'c'); // Result: ['a', 'b', 'c'] // Navigate by index path Tree\at_index($tree, [0, 0]); // 'c' Tree\at_index($tree, [1]); // 'd' // Search for values Tree\find($tree, fn($x) => $x === 'c'); // 'c' Tree\contains($tree, 'd'); // true Tree\any($tree, fn($x) => $x === 'b'); // true Tree\all($tree, fn($x) => Str\length($x) === 1); // true ``` ### Custom Serialization The `traverse()` function gives you full control over how the tree is serialized: ```php use Psl\Iter; use Psl\Str; use Psl\Tree; $tree = Tree\tree('root', [Tree\leaf('a'), Tree\leaf('b')]); // Build a custom string representation $result = Tree\traverse( $tree, /** * @param non-empty-string $value * @param Closure(): list $traverse * * @return non-empty-string */ function (string $value, Closure $traverse): string { $children = $traverse(); return Iter\is_empty($children) ? $value : $value . '(' . Str\join($children, ',') . ')'; }, ); // Result: 'root(a,b)' ``` See [src/Psl/Tree/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/tree/src/Psl/Tree/) for the full API. --- # Binary The `Binary` component provides typed functions and classes for encoding and decoding binary data, replacing PHP's cryptic `pack()` / `unpack()` format strings with a clear, type-safe API. It supports explicit endianness control via the `Endianness` enum. ## Why Use This? PHP's `pack()` and `unpack()` rely on single-character format codes (`N`, `v`, `J`, `P`, etc.) that are hard to remember and easy to misuse. PSL's Binary component provides: - **Named functions** like `encode_u32()` and `decode_i16()` instead of cryptic format strings. - **Explicit endianness** via an enum instead of remembering which format code is big-endian vs little-endian. - **Type-safe validation** that throws on overflow or underflow. - **An immutable Writer** for building binary messages in memory. - **A cursor-based Reader** for parsing binary data from a string. - **Handle-based Writer and Reader** for streaming binary data directly to/from IO handles without buffering. ## One-Shot Functions For encoding or decoding individual values, use the standalone functions. Each function validates its input and throws on error. ```php use Psl\Binary; use Psl\Binary\Endianness; use Psl\IO; // Encode an unsigned 16-bit integer in big-endian (network byte order) $bytes = Binary\encode_u16(0x0102, Endianness::Big); IO\write_line('u16 big-endian bytes: %s', bin2hex($bytes)); // 0102 // Decode it back $value = Binary\decode_u16($bytes, Endianness::Big); IO\write_line('Decoded u16: 0x%04X', $value); // 0x0102 // Encode a signed 32-bit integer in little-endian $bytes = Binary\encode_i32(-1, Endianness::Little); IO\write_line('i32 little-endian bytes: %s', bin2hex($bytes)); // ffffffff // Decode it back $value = Binary\decode_i32($bytes, Endianness::Little); IO\write_line('Decoded i32: %d', $value); // -1 // Encode a 64-bit float $bytes = Binary\encode_f64(3.14, Endianness::Big); IO\write_line('f64 bytes: %s', bin2hex($bytes)); IO\write_line('Decoded f64: %f', Binary\decode_f64($bytes, Endianness::Big)); // 3.140000 ``` ## Writer The `Writer` is an immutable builder for binary data. Each method returns a new instance with the appended bytes. Use this when you need the result as a string. ```php use Psl\Binary\Endianness; use Psl\Binary\Writer; use Psl\IO; // Build a binary protocol message: version(u8) + type(u16) + length(u32) + payload // Writer::default() creates a new Writer with default settings (big-endian) $message = Writer::default() ->u8(1) // protocol version ->u16(0x0042) // message type ->u32(5) // payload length ->bytes('Hello') // payload ->toString(); IO\write_line('Message hex: %s', bin2hex($message)); // 01004200000005 48656c6c6f // The Writer is immutable -- each call returns a new instance $base = new Writer(endianness: Endianness::Little); $a = $base->u16(1)->u16(2); $b = $base->u16(3)->u16(4); IO\write_line('A: %s', bin2hex($a->toString())); // 01000200 IO\write_line('B: %s', bin2hex($b->toString())); // 03000400 // You can also mix endianness per call $mixed = new Writer(endianness: Endianness::Big) ->u16(0x0102) // big-endian (default) ->u16(0x0304, Endianness::Little) // little-endian override ->toString(); IO\write_line('Mixed: %s', bin2hex($mixed)); // 01020403 ``` ## Reader The `Reader` provides cursor-based sequential reading of binary data from a string. Each read advances the internal cursor. ```php use Psl\Binary\Endianness; use Psl\Binary\Reader; use Psl\Binary\Writer; use Psl\IO; // Build a message, then parse it $data = new Writer(endianness: Endianness::Big) ->u8(1) // version ->u16(0x0042) // type ->u32(5) // payload length ->bytes('Hello') // payload ->toString(); $reader = new Reader($data, Endianness::Big); $version = $reader->u8(); $type = $reader->u16(); $length = $reader->u32(); $payload = $reader->bytes($length); IO\write_line('Version: %d', $version); // 1 IO\write_line('Type: 0x%04X', $type); // 0x0042 IO\write_line('Length: %d', $length); // 5 IO\write_line('Payload: %s', $payload); // Hello // Track cursor position IO\write_line('Cursor: %d / %d', $reader->cursor(), $reader->length()); // 12 / 12 IO\write_line('Remaining: %d', $reader->remaining()); // 0 IO\write_line('Consumed: %s', $reader->isConsumed() ? 'yes' : 'no'); // yes ``` ## Handle-Based IO `HandleWriter` and `HandleReader` operate directly on `IO\WriteHandleInterface` and `IO\ReadHandleInterface`, writing and reading binary data without buffering entire messages in PHP memory. This is ideal for network protocols over TCP/Unix sockets. ```php use Psl\Binary\Endianness; use Psl\Binary\HandleReader; use Psl\Binary\HandleWriter; use Psl\IO; use Psl\IO\MemoryHandle; // HandleWriter writes directly to any IO\WriteHandleInterface. // In production, this could be a TCP socket - here we use MemoryHandle for demo. $handle = new MemoryHandle(); $writer = new HandleWriter($handle, Endianness::Big); // version $writer->u8(1); // type $writer->u16(0x0042); // payload length $writer->u32(5); // payload $writer->bytes('Hello'); IO\write_line('Wrote %d bytes to handle', strlen($handle->getBuffer())); // Seek back to start so we can read what was written $handle->seek(0); // HandleReader reads directly from any IO\ReadHandleInterface $reader = new HandleReader($handle, Endianness::Big); $version = $reader->u8(); $type = $reader->u16(); $length = $reader->u32(); $payload = $reader->bytes($length); IO\write_line('Version: %d', $version); // 1 IO\write_line('Type: 0x%04X', $type); // 0x0042 IO\write_line('Length: %d', $length); // 5 IO\write_line('Payload: %s', $payload); // Hello ``` ## Length-Prefixed Bytes All writers and readers support length-prefixed byte strings, a common pattern in binary protocols where the payload length is written before the payload itself. The prefix size determines the maximum payload length. ```php use Psl\Binary\Reader; use Psl\Binary\Writer; use Psl\IO; // Length-prefixed bytes: write the payload length as a typed integer, // followed by the raw payload bytes, in a single method call. $data = Writer::default() ->u8(1) // version ->u16(0x0042) // message type ->u32PrefixedBytes('Hello, PSL!') // u32 length prefix + payload ->u8PrefixedBytes('OK') // u8 length prefix + short status ->toString(); IO\write_line('Message hex: %s', bin2hex($data)); // Read it back $reader = new Reader($data); $version = $reader->u8(); $type = $reader->u16(); $payload = $reader->u32PrefixedBytes(); // reads u32 length, then that many bytes $status = $reader->u8PrefixedBytes(); IO\write_line('Version: %d', $version); // 1 IO\write_line('Type: 0x%04X', $type); // 0x0042 IO\write_line('Payload: %s', $payload); // Hello, PSL! IO\write_line('Status: %s', $status); // OK // skip() advances the reader without returning the data $data2 = Writer::default()->u32(0xDEAD)->u32PrefixedBytes('Important')->toString(); // header we want to skip $reader2 = new Reader($data2); $reader2->skip(4); // skip the 4-byte header IO\write_line('Skipped to: %s', $reader2->u32PrefixedBytes()); // Important ``` ## Implementing Custom Writers and Readers The `WriterConvenienceMethodsTrait` and `ReaderConvenienceMethodsTrait` provide default implementations of `skip()` and the length-prefixed methods. When implementing `WriterInterface` or `ReaderInterface`, use these traits to avoid re-implementing the convenience methods: ```php use Psl\Binary\WriterInterface; use Psl\Binary\WriterConvenienceMethodsTrait; final class MyCustomWriter implements WriterInterface { use WriterConvenienceMethodsTrait; // Only implement the primitive methods: u8, u16, u32, u64, // i8, i16, i32, i64, f32, f64, bytes // The prefixed methods come from the trait for free. } ``` ## Supported Types | Type | Size | Signed | Function Pair | |------|------|--------|---------------| | u8 | 1 byte | No | `encode_u8` / `decode_u8` | | u16 | 2 bytes | No | `encode_u16` / `decode_u16` | | u32 | 4 bytes | No | `encode_u32` / `decode_u32` | | u64 | 8 bytes | No | `encode_u64` / `decode_u64` | | i8 | 1 byte | Yes | `encode_i8` / `decode_i8` | | i16 | 2 bytes | Yes | `encode_i16` / `decode_i16` | | i32 | 4 bytes | Yes | `encode_i32` / `decode_i32` | | i64 | 8 bytes | Yes | `encode_i64` / `decode_i64` | | f32 | 4 bytes | N/A | `encode_f32` / `decode_f32` | | f64 | 8 bytes | N/A | `encode_f64` / `decode_f64` | See [src/Psl/Binary/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/binary/src/Psl/Binary/) for the full API. --- # Encoding The `Encoding` component provides functions for encoding and decoding binary data to and from text-safe representations. It supports Base64 (with multiple variants) and hexadecimal encoding, making it straightforward to prepare binary data for transport over text-based protocols like HTTP, email, or JSON. ## Base64 ### Encoding and Decoding ```php use Psl\Encoding\Base64; $encoded = Base64\encode('Hello, World!'); // 'SGVsbG8sIFdvcmxkIQ==' $decoded = Base64\decode($encoded); // 'Hello, World!' ``` ### URL-Safe Variant Standard Base64 uses `+` and `/`, which conflict with URLs. The `UrlSafe` variant replaces them with `-` and `_`: ```php use Psl\Encoding\Base64; use Psl\SecureRandom; // Standard encoding may produce URL-unfriendly characters $binaryToken = SecureRandom\bytes(8); $token = Base64\encode($binaryToken); // URL-safe encoding is better for query parameters, filenames, and tokens $token = Base64\encode($binaryToken, Base64\Variant::UrlSafe); $original = Base64\decode($token, Base64\Variant::UrlSafe); ``` The `Variant` enum supports `Standard`, `UrlSafe`, `DotSlash`, `DotSlashOrdered`, and `Mime` for specialized use cases. The `Mime` variant uses the standard alphabet but wraps output at 76 characters with CRLF line endings per RFC 2045, and strips whitespace on decode. ### Padding Control Both `encode()` and `decode()` accept a padding parameter. You can strip padding on encode and decode without it: ```php use Psl\Encoding\Base64; $encoded = Base64\encode('Hello!', padding: false); // 'SGVsbG8h' (no trailing '=') $decoded = Base64\decode($encoded, explicitPadding: false); // 'Hello!' ``` ### Error Handling Decoding invalid input throws typed exceptions rather than returning `false`: ```php use Psl\Encoding\Base64; use Psl\Encoding\Exception; try { Base64\decode('not-valid-base64!'); } catch (Exception\RangeException $e) { echo $e->getMessage() . "\n"; } ``` ## Hex Hexadecimal encoding converts each byte into two hex characters. This is useful for displaying binary data like hashes, checksums, or cryptographic keys in a human-readable form. ```php use Psl\Encoding\Exception; use Psl\Encoding\Hex; $hex = Hex\encode('Hello'); // '48656c6c6f' $binary = Hex\decode('48656c6c6f'); // 'Hello' // Invalid hex throws immediately try { Hex\decode('xyz'); } catch (Exception\RangeException $e) { echo $e->getMessage() . "\n"; } // Odd-length strings are rejected try { Hex\decode('abc'); } catch (Exception\RangeException $e) { echo $e->getMessage() . "\n"; } ``` ## Quoted-Printable Quoted-printable encoding represents 8-bit data using only printable ASCII characters, as defined by RFC 2045 §6.7. It is commonly used in email (MIME) to encode content that is mostly ASCII with occasional special characters. Unlike Base64, quoted-printable keeps readable text readable. ```php use Psl\Encoding\QuotedPrintable; use Psl\IO; $encoded = QuotedPrintable\encode("Hello =World!\r\nLine 2 with trailing space "); IO\write_line('Encoded: %s', $encoded); $decoded = QuotedPrintable\decode($encoded); IO\write_line('Decoded: %s', $decoded); // Custom line length $short = QuotedPrintable\encode(str_repeat('A', 50), maxLineLength: 30); IO\write_line('Short lines: %s', $short); // Custom line ending $unix = QuotedPrintable\encode("line1\r\nline2", lineEnding: "\n"); IO\write_line('Unix endings: %s', $unix); ``` Both `encode()` and `encode_line()` accept optional `$max_line_length` (default 76) and `$line_ending` (default `"\r\n"`) parameters. ## Encoded-Word (RFC 2047) Encoded-word encoding is used in MIME headers (Subject, From, etc.) to represent non-ASCII text. `encode()` automatically selects Q-encoding (for mostly ASCII text) or B-encoding (for mostly non-ASCII text) based on the proportion of non-printable bytes. ```php use Psl\Encoding\EncodedWord; use Psl\IO; // Encode a non-ASCII string for use in a MIME header $encoded = EncodedWord\encode("caf\xC3\xA9 latt\xC3\xA9 is great"); IO\write_line('Encoded: %s', $encoded); // Decode encoded-words back to UTF-8 $decoded = EncodedWord\decode($encoded); IO\write_line('Decoded: %s', $decoded); // Decode a B-encoded header $subject = EncodedWord\decode('=?utf-8?B?Y2Fmw6k=?='); IO\write_line('Subject: %s', $subject); // Decode a Q-encoded header with underscores as spaces $from = EncodedWord\decode('=?utf-8?Q?John_Doe?='); IO\write_line('From: %s', $from); // Plain ASCII passes through unchanged $plain = EncodedWord\encode('Hello, World!'); IO\write_line('Plain: %s', $plain); ``` `encode()` accepts an optional `$charset` parameter of type `Psl\Str\Encoding` (default `Encoding::Utf8`). `decode()` handles charset conversion automatically, and per RFC 2047 §6.2, whitespace between adjacent encoded-words is removed. ## Streaming IO Handles Each encoding namespace (Base64, Hex, QuotedPrintable) provides four IO handle decorators that transparently encode or decode data as it flows through: - **`EncodingReadHandle`** wraps a raw readable handle; read() returns encoded data - **`DecodingReadHandle`** wraps an encoded readable handle; read() returns decoded data - **`EncodingWriteHandle`** accepts raw data via write(); encodes and writes to the inner handle - **`DecodingWriteHandle`** accepts encoded data via write(); decodes and writes to the inner handle Write handles have a `flush()` method to encode/decode any remaining buffered data. This bridges `Psl\IO` with `Psl\Encoding`, allowing you to compose encoding into any IO pipeline: ```php use Psl\Encoding\Base64; use Psl\IO; // Encode: wrap a raw handle, read() returns base64-encoded output $raw = new IO\MemoryHandle('Hello, World!'); $encoder = new Base64\EncodingReadHandle($raw); $encoded = $encoder->readAll(); IO\write_line('Encoded: %s', $encoded); // Decode: wrap an encoded handle, read() returns raw bytes $encodedHandle = new IO\MemoryHandle($encoded); $decoder = new Base64\DecodingReadHandle($encodedHandle); $decoded = $decoder->readAll(); IO\write_line('Decoded: %s', $decoded); // Write handles work the other direction $output = new IO\MemoryHandle(); $encodingWriter = new Base64\EncodingWriteHandle($output); $encodingWriter->writeAll('Binary data here'); $encodingWriter->flush(); $output->seek(0); IO\write_line('Written encoded: %s', $output->readAll()); ``` Read handles also implement `BufferedReadHandleInterface`, providing `readByte()`, `readLine()`, `readUntil()`, and `readUntilBounded()` directly on the decoded/encoded output with zero wrapper overhead. See [src/Psl/Encoding/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/encoding/src/Psl/Encoding/) for the full API. --- # Html The `Html` component provides functions for encoding, decoding, and stripping HTML entities. It wraps PHP's `htmlentities`, `htmlspecialchars`, and related functions with a cleaner API and an `Encoding` enum for character set selection. ## Usage ### Encoding `encode()` converts all applicable characters to HTML entities, while `encode_special_characters()` only converts the special characters (`&`, `"`, `'`, `<`, `>`): ```php use Psl\Html; Html\encode('

"Hello" & welcome

'); // '<p>"Hello" & welcome</p>' // encode_special_characters is lighter -- only converts &, ", ', <, > Html\encode_special_characters(''); // '<script>alert("xss")</script>' ``` Use `encode_special_characters()` when building HTML output from user input -- it is the safer default for preventing XSS while keeping non-special characters readable. ### Avoiding Double Encoding Both functions accept a `$double_encoding` parameter. Set it to `false` to preserve existing entities: ```php use Psl\Html; Html\encode('& is an ampersand', doubleEncoding: false); // '& is an ampersand' (not double-encoded to '&amp;') ``` ### Decoding ```php use Psl\Html; Html\decode('<p>Hello</p>'); // '

Hello

' Html\decode_special_characters('<p>Hello</p>'); // '

Hello

' ``` ### Stripping Tags Remove HTML and PHP tags from a string, optionally keeping specific tags: ```php use Psl\Html; Html\strip_tags('

Hello World

'); // 'Hello World' Html\strip_tags('

Hello World

', ['b']); // 'Hello World' ``` ### Character Encoding All encoding/decoding functions accept an `Encoding` enum to specify the character set. The default is UTF-8: ```php use Psl\Html; $html = '

"Hello" & welcome

'; Html\encode($html, encoding: Html\Encoding::Iso88591); Html\decode('<p>Hello</p>', Html\Encoding::ShiftJis); ``` The `Encoding` enum supports UTF-8, ISO-8859-1, ISO-8859-15, Windows-1251, Windows-1252, Big5, GB2312, Shift_JIS, EUC-JP, KOI8-R, and several other character sets. See [src/Psl/Html/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/html/src/Psl/Html/) for the full API. --- # Json The `Json` component provides JSON encoding and decoding that throws typed exceptions instead of returning `false` or requiring manual `json_last_error()` checks. It defaults to sensible flags (unescaped Unicode, unescaped slashes, preserved zero fractions) so encoded output is clean and predictable. ## Usage ### Encoding ```php use Psl\Json; Json\encode(['name' => 'Alice', 'score' => 9.0]); // '{"name":"Alice","score":9.0}' // Pretty-print for readability Json\encode(['name' => 'Alice'], true); // '{ // "name": "Alice" // }' // Unicode and slashes are unescaped by default Json\encode(['path' => '/home/user', 'greeting' => "\u{1F44B}"]); // '{"path":"/home/user","greeting":"..."}' (actual wave emoji in output) ``` ### Decoding ```php use Psl\Json; $_ = Json\decode('{"name":"Alice","age":30}'); // ['name' => 'Alice', 'age' => 30] // Invalid JSON throws immediately try { Json\decode('not json'); } catch (Json\Exception\DecodeException $e) { echo $e->getMessage() . "\n"; } ``` ### Typed Decoding `typed()` combines JSON decoding with PSL's `Type` system. The decoded data is validated and coerced into the expected shape in a single step -- no manual array key checks or type casting: ```php use Psl\Json; use Psl\Type; // Decode and validate as a specific shape $user = Json\typed('{"name":"Alice","age":30}', Type\shape([ 'name' => Type\string(), 'age' => Type\int(), ])); // $user['name'] is string, $user['age'] is int -- guaranteed // If the JSON structure doesn't match, you get a clear exception try { Json\typed('{"name":"Alice"}', Type\shape([ 'name' => Type\string(), 'age' => Type\int(), ])); } catch (Json\Exception\DecodeException $e) { echo $e->getMessage() . "\n"; } // Works with any Type -- vectors, optionals, nested shapes $ids = Json\typed('[1, 2, 3]', Type\vec(Type\int())); // [1, 2, 3] as list ``` ## Why Use This Over json_encode/json_decode? - **Exceptions by default**: No need to pass `JSON_THROW_ON_ERROR` or check `json_last_error()` - **Clean defaults**: Unicode characters and slashes are not escaped, zero fractions are preserved - **Type safety with `typed()`**: Validate the decoded structure against a `Type` in one call, eliminating manual `isset` / `is_array` checks - **Consistent API**: Both encoding and decoding errors throw from the same `Json\Exception` hierarchy See [src/Psl/Json/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/json/src/Psl/Json/) for the full API. --- # Punycode The `Punycode` component provides RFC 3492 Punycode encoding and decoding, the standard algorithm for representing Unicode strings in the ASCII-only subset used by internationalized domain names (IDNA). ## Usage ```php use Psl\IO; use Psl\Punycode; // Encode Unicode to Punycode $encoded = Punycode\encode('münchen'); // "mnchen-3ya" IO\write_line('%s', $encoded); // Decode Punycode to Unicode $decoded = Punycode\decode('mnchen-3ya'); // "münchen" IO\write_line('%s', $decoded); // Round-trip $encoded = Punycode\encode('日本語'); $decoded = Punycode\decode($encoded); // "日本語" IO\write_line('%s', $decoded); ``` ## Standards | RFC | Title | |-----|-------| | [RFC 3492](https://datatracker.ietf.org/doc/html/rfc3492) | Punycode: A Bootstring Encoding of Unicode for IDNA | See [src/Psl/Punycode/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/punycode/src/Psl/Punycode/) for the full API. --- # Regex The `Regex` component provides type-safe regular expression functions. PHP's `preg_*` functions return `false` on invalid patterns and use `null` for unmatched groups, making it easy to silently propagate errors. PSL's `Regex` functions throw `InvalidPatternException` for bad patterns and support typed capture groups so you know exactly what shape your matches will have. ## Usage ### Checking for Matches ```php use Psl\Regex; Regex\matches('hello@example.com', '/^[^@]+@[^@]+$/'); // true Regex\matches('not-an-email', '/^[^@]+@[^@]+$/'); // false Regex\matches('foo bar foo', '/foo/', 4); // true (from offset) ``` ### Extracting Matches `first_match()` returns the first match as an array, or `null` if nothing matched: ```php use Psl\Regex; $match = Regex\first_match('Order #12345', '/Order #(\d+)/'); // ['Order #12345', '12345'] ``` ### Typed Capture Groups Pass a `Type` shape to get a well-typed result that validates the match structure at runtime: ```php use Psl\Regex; use Psl\Type; $pattern = '/(?P\d{4})-(?P\d{2})-(?P\d{2})/'; $match = Regex\first_match('Date: 2025-03-15', $pattern, Type\shape([ 0 => Type\string(), 'year' => Type\string(), 'month' => Type\string(), 'day' => Type\string(), ])); // $match['year'] === '2025', $match['month'] === '03', $match['day'] === '15' ``` The `capture_groups()` helper builds a shape type from a list of expected group keys: ```php use Psl\Regex; $match = Regex\first_match('Hello World', '/(Hello) (World)/', Regex\capture_groups([1, 2])); // $match[0] === 'Hello World', $match[1] === 'Hello', $match[2] === 'World' ``` ### Finding All Matches `every_match()` returns all matches as a list, or `null` if none matched: ```php use Psl\Regex; $matches = Regex\every_match('prices: $10, $20, $30', '/\$(\d+)/'); // [['$10', '10'], ['$20', '20'], ['$30', '30']] ``` ### Search and Replace ```php use Psl\Regex; use Psl\Str; Regex\replace('hello world', '/world/', 'PHP'); // 'hello PHP' // Dynamic replacement with a callback Regex\replace_with('hello world', '/\b(\w)/', fn($m) => Str\uppercase($m[1])); // 'Hello World' // Replace multiple patterns at once Regex\replace_every('foo bar baz', [ '/foo/' => 'one', '/bar/' => 'two', '/baz/' => 'three', ]); // 'one two three' ``` ### Splitting ```php use Psl\Regex; Regex\split('one, two, three', '/,\s*/'); // ['one', 'two', 'three'] Regex\split('a-b-c-d', '/-/', 2); // ['a', 'b-c-d'] ``` ## Error Handling All functions throw instead of returning error sentinels. An invalid pattern like `/unclosed(group` throws `Regex\Exception\InvalidPatternException` immediately rather than returning `false`. See [src/Psl/Regex/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/regex/src/Psl/Regex/) for the full API. --- # Compression The `Compression` component provides streaming compression and decompression abstractions for IO handles. It defines compressor and decompressor interfaces, and four handle decorators that transparently compress or decompress data as it flows through. This component is an abstraction layer -- it does not include any compression algorithms. Concrete implementations (e.g. deflate, gzip, brotli, zstd) are provided by separate packages that implement `CompressorInterface` and `DecompressorInterface`. ## Interfaces Two interfaces define the compression contract: - **`CompressorInterface`** -- `push(string $data): string` to incrementally compress, `finish(): string` to finalize and reset - **`DecompressorInterface`** -- `push(string $data): string` to incrementally decompress, `finish(): string` to finalize and reset After `finish()`, the instance resets to initial state and can be reused for a new stream. Compression and decompression are inherently stateful -- a single instance must not be used concurrently across multiple fibers or streams. ## Readers `CompressingReadHandle` and `DecompressingReadHandle` wrap a readable handle and transform data on read. Both implement `ReadHandleInterface`. Wrap with `IO\Reader` for buffered methods like `readLine()` and `readUntil()`. Both accept an optional `$chunkSize` parameter (default 8192) controlling how many bytes are read from the inner handle per iteration. Increase it for large file compression, decrease it to reduce memory usage per stream. ```php use Psl\Compression; use Psl\IO; // CompressingReadHandle wraps a readable handle and compresses data on read. // DecompressingReadHandle does the reverse -- decompresses data on read. // Both implement ReadHandleInterface. Wrap with IO\Reader for buffered methods. $source = new IO\MemoryHandle('hello world'); /** @var Compression\CompressorInterface $compressor */ $handle = new Compression\CompressingReadHandle($source, $compressor); $compressed = $handle->readAll(); IO\write_line('Compressed: %s', $compressed); ``` ## Writers `CompressingWriteHandle` and `DecompressingWriteHandle` wrap a writable handle and transform data on write. Both implement `BufferedWriteHandleInterface`. Call `flush()` to finalize the handle and write any remaining buffered data. ```php use Psl\Async; use Psl\Compression; use Psl\DateTime\Duration; use Psl\IO; // CompressingWriteHandle wraps a writable handle and compresses data on write. // DecompressingWriteHandle does the reverse -- decompresses data on write. // Both implement BufferedWriteHandleInterface. Call flush() to finalize. $output = new IO\MemoryHandle(); /** @var Compression\CompressorInterface $compressor */ $writer = new Compression\CompressingWriteHandle($output, $compressor); $writer->writeAll('hello world'); $writer->flush(); // finalize the compression stream // flush() accepts an optional CancellationTokenInterface for timeout control $writer->flush(new Async\TimeoutCancellationToken(Duration::seconds(5))); $output->seek(0); IO\write_line('Result: %s', $output->readAll()); ``` `flush()` accepts an optional `CancellationTokenInterface` parameter, so you can set a timeout on the finalization when writing to network handles. See [src/Psl/Compression/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/compression/src/Psl/Compression/) for the full API. --- # File The `File` component provides typed file handles for reading and writing files. It builds on the `IO` component's handle interfaces, adding file-specific concerns like write modes and advisory locking. ## Quick Read and Write For common cases, use the convenience functions: ```php use Psl\File; use Psl\Filesystem; use Psl\IO; $tmp = Filesystem\create_temporary_file(prefix: 'psl_file_'); // Write to a file (creates it if it does not exist) File\write($tmp, '{"key": "value"}'); // Read an entire file $content = File\read($tmp); IO\write_line('Full content: %s', $content); // Read with offset and length $header = File\read($tmp, offset: 0, length: 6); IO\write_line('Header: %s', $header); // Overwrite with truncation File\write($tmp, 'New content', File\WriteMode::Truncate); IO\write_line('After truncate: %s', File\read($tmp)); Filesystem\delete_file($tmp); ``` Both `File\read()` and `File\write()` automatically acquire a lock (shared for reads, exclusive for writes) and release it when done. ## Write Modes When opening a file for writing, you choose a `WriteMode` that controls how the file pointer is positioned and whether the file must already exist: | Mode | Behavior | |------|----------| | `OpenOrCreate` | Opens or creates the file. Pointer at the beginning. Existing content is preserved. **(default)** | | `Truncate` | Opens or creates the file and truncates it to zero length. | | `Append` | Opens or creates the file. All writes go to the end regardless of seek position. | | `MustCreate` | Creates a new file. Throws if the file already exists. | ```php use Psl\File; use Psl\Filesystem; use Psl\IO; $logFile = Filesystem\create_temporary_file(prefix: 'psl_log_'); $lockFile = sys_get_temp_dir() . '/psl-session-' . uniqid() . '.lock'; // Append a log line File\write($logFile, "event occurred\n", File\WriteMode::Append); File\write($logFile, "another event\n", File\WriteMode::Append); IO\write_line('Log contents: %s', File\read($logFile)); // Ensure we are creating a fresh file $id = 'session-abc123'; File\write($lockFile, $id, File\WriteMode::MustCreate); IO\write_line('Lock file contents: %s', File\read($lockFile)); Filesystem\delete_file($logFile); Filesystem\delete_file($lockFile); ``` ## File Handles For more control, open a file handle directly. Handles implement the `IO` handle interfaces and add `getPath()`, `getSize()`, and locking. ```php use Psl\File; use Psl\Filesystem; use Psl\IO; use Psl\Str; $csvFile = Filesystem\create_temporary_file(prefix: 'psl_csv_'); $outputFile = Filesystem\create_temporary_file(prefix: 'psl_out_'); $datFile = Filesystem\create_temporary_file(prefix: 'psl_dat_'); // Prepare CSV data File\write($csvFile, "name,age\nAlice,30\nBob,25\n"); // Read-only $handle = File\open_read_only($csvFile); $all = $handle->readAll(); $handle->close(); IO\write_line('CSV data: %s', Str\trim_right($all)); // Write-only with truncation $handle = File\open_write_only($outputFile, File\WriteMode::Truncate); $handle->writeAll("line 1\nline 2\n"); $handle->close(); IO\write_line('Output: %s', Str\trim_right(File\read($outputFile))); // Read-write File\write($datFile, 'hello world'); $handle = File\open_read_write($datFile); $existing = $handle->readAll(); $handle->seek(0); $handle->writeAll(Str\uppercase($existing)); $handle->close(); IO\write_line('Uppercased: %s', File\read($datFile)); Filesystem\delete_file($csvFile); Filesystem\delete_file($outputFile); Filesystem\delete_file($datFile); ``` ## File Locking File handles support advisory locking via `lock()` and `tryLock()`. The returned `Lock` object is released by calling `release()` or when it is garbage collected. - **`LockType::Shared`** -- Multiple processes can hold a shared lock simultaneously. Use for reads. - **`LockType::Exclusive`** -- Only one process can hold an exclusive lock. Use for writes. ```php use Psl\File; use Psl\Filesystem; use Psl\IO; $counterFile = Filesystem\create_temporary_file(prefix: 'psl_counter_'); File\write($counterFile, '0'); $handle = File\open_read_write($counterFile); $lock = $handle->lock(File\LockType::Exclusive); $count = (int) $handle->readAll(); $handle->seek(0); $handle->writeAll((string) ($count + 1)); $lock->release(); $handle->close(); IO\write_line('Counter incremented to: %s', File\read($counterFile)); Filesystem\delete_file($counterFile); ``` `tryLock()` acquires the lock immediately or throws `AlreadyLockedException`: ```php use Psl\File; use Psl\Filesystem; use Psl\IO; $tmp = Filesystem\create_temporary_file(prefix: 'psl_lock_'); $handle = File\open_read_write($tmp); try { $lock = $handle->tryLock(File\LockType::Exclusive); IO\write_line('Lock acquired successfully'); $lock->release(); } catch (File\Exception\AlreadyLockedException) { IO\write_line('Another process holds the lock'); } $handle->close(); Filesystem\delete_file($tmp); ``` ## Blocking Behavior in Async Code All file operations are blocking: they block the current fiber until the operation completes. This is inherent to filesystem I/O; operating systems do not provide truly non-blocking file I/O the way they do for sockets. In concurrent applications, this means a file read or write in one fiber will prevent other fibers from running until it finishes. For most workloads this is fine since file I/O is fast, but if you are doing heavy file I/O alongside latency-sensitive network operations, consider offloading file work to a separate process. The [`amphp/file`](https://github.com/amphp/file) package does exactly this: it spawns a worker process and delegates all file operations to it, communicating over IPC. Because amphp uses the same RevoltPHP event loop as PSL's async component, it integrates seamlessly: ```php use Amp\File; use Psl\Async; Async\main(static function(): int { // Non-blocking file read; other fibers can run while this waits $content = File\read('/path/to/large-file.dat'); return 0; }); ``` ## Integration with IO Because file handles implement the `IO` interfaces, they work with any code that accepts `IO\ReadHandleInterface` or `IO\WriteHandleInterface`: ```php use Psl\File; use Psl\Filesystem; use Psl\IO; $sourceFile = Filesystem\create_temporary_file(prefix: 'psl_src_'); $destFile = Filesystem\create_temporary_file(prefix: 'psl_dst_'); File\write($sourceFile, 'binary data to copy'); $source = File\open_read_only($sourceFile); $dest = File\open_write_only($destFile, File\WriteMode::Truncate); IO\copy($source, $dest); $source->close(); $dest->close(); IO\write_line('Copied: %s', File\read($destFile)); Filesystem\delete_file($sourceFile); Filesystem\delete_file($destFile); ``` See [src/Psl/File/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/file/src/Psl/File/) for the full API. --- # Filesystem The `Filesystem` component provides type-safe functions for common file system operations. It replaces PHP's procedural file functions with proper error handling through exceptions, clear parameter types, and consistent behavior. Unlike the `File` component (which deals with reading and writing file contents through handles), `Filesystem` focuses on managing files and directories themselves: checking existence, creating, copying, deleting, and inspecting metadata. ## Checking Existence and Type ```php use Psl\Filesystem; use Psl\IO; // Create temp directory and file for demonstration $dir = sys_get_temp_dir() . '/psl-fs-check-' . uniqid(); Filesystem\create_directory($dir); $file = $dir . '/test.txt'; Filesystem\create_file($file); $link = $dir . '/test-link'; Filesystem\create_symbolic_link($file, $link); IO\write_line('exists(file): %s', Filesystem\exists($file) ? 'true' : 'false'); IO\write_line('is_file(file): %s', Filesystem\is_file($file) ? 'true' : 'false'); IO\write_line('is_directory(dir): %s', Filesystem\is_directory($dir) ? 'true' : 'false'); IO\write_line('is_symbolic_link: %s', Filesystem\is_symbolic_link($link) ? 'true' : 'false'); IO\write_line('is_readable(file): %s', Filesystem\is_readable($file) ? 'true' : 'false'); IO\write_line('is_writable(file): %s', Filesystem\is_writable($file) ? 'true' : 'false'); Filesystem\delete_file($link); Filesystem\delete_file($file); Filesystem\delete_directory($dir); ``` ## Creating Files and Directories ```php use Psl\Filesystem; use Psl\IO; $base = sys_get_temp_dir() . '/psl-fs-create-' . uniqid(); // Create a file (parent directories are created automatically) $file = $base . '/nested/file.txt'; Filesystem\create_file($file); IO\write_line('File created: %s', Filesystem\is_file($file) ? 'yes' : 'no'); // Create a directory (recursive by default) $dir = $base . '/another/nested/dir'; Filesystem\create_directory($dir); Filesystem\create_directory($dir, 0o755); IO\write_line('Directory created: %s', Filesystem\is_directory($dir) ? 'yes' : 'no'); // Create a temporary file $tmp = Filesystem\create_temporary_file(); IO\write_line('Temp file: %s', $tmp); $tmp2 = Filesystem\create_temporary_file($base, prefix: 'app_'); IO\write_line('Prefixed temp file: %s', Filesystem\get_basename($tmp2)); // Clean up Filesystem\delete_file($tmp); Filesystem\delete_file($tmp2); Filesystem\delete_directory($base, recursive: true); ``` ## Copying and Deleting ```php use Psl\File; use Psl\Filesystem; use Psl\IO; $base = sys_get_temp_dir() . '/psl-fs-copy-' . uniqid(); Filesystem\create_directory($base); $src = $base . '/source.txt'; $dst = $base . '/dest.txt'; File\write($src, 'hello world'); // Copy a file (preserves executable bits) Filesystem\copy($src, $dst); IO\write_line('Copied: %s', File\read($dst)); // Copy with overwrite File\write($src, 'updated content'); Filesystem\copy($src, $dst, overwrite: true); IO\write_line('Overwritten: %s', File\read($dst)); // Delete a file Filesystem\delete_file($dst); IO\write_line('File deleted: %s', !Filesystem\exists($dst) ? 'yes' : 'no'); // Delete a directory (empty, or recursive) $subDir = $base . '/subdir'; Filesystem\create_directory($subDir); Filesystem\delete_directory($subDir); // Delete a directory with contents recursively Filesystem\delete_directory($base, recursive: true); IO\write_line('Directory deleted: %s', !Filesystem\exists($base) ? 'yes' : 'no'); ``` ## Metadata and Permissions ```php use Psl\File; use Psl\Filesystem; use Psl\IO; $tmp = Filesystem\create_temporary_file(prefix: 'psl_meta_'); File\write($tmp, 'some content here'); $bytes = Filesystem\file_size($tmp); $mtime = Filesystem\get_modification_time($tmp); $perms = Filesystem\get_permissions($tmp); $owner = Filesystem\get_owner($tmp); IO\write_line('Size: %d bytes', $bytes); IO\write_line('Modified: %s', date('Y-m-d H:i:s', $mtime)); IO\write_line('Permissions: %o', $perms); IO\write_line('Owner UID: %d', $owner); Filesystem\change_permissions($tmp, 0o644); IO\write_line('New permissions: %o', Filesystem\get_permissions($tmp)); Filesystem\delete_file($tmp); ``` ## Symbolic Links ```php use Psl\Filesystem; use Psl\IO; $base = sys_get_temp_dir() . '/psl-fs-links-' . uniqid(); Filesystem\create_directory($base); $target = $base . '/target.txt'; $symlink = $base . '/symlink.txt'; $hardlink = $base . '/hardlink.txt'; Filesystem\create_file($target); Filesystem\create_symbolic_link($target, $symlink); Filesystem\create_hard_link($target, $hardlink); $resolved = Filesystem\read_symbolic_link($symlink); IO\write_line('Symlink points to: %s', $resolved); IO\write_line('Is symbolic link: %s', Filesystem\is_symbolic_link($symlink) ? 'true' : 'false'); IO\write_line('Hard link exists: %s', Filesystem\is_file($hardlink) ? 'true' : 'false'); Filesystem\delete_directory($base, recursive: true); ``` ## Path Operations ```php use Psl\Filesystem; use Psl\IO; $base = sys_get_temp_dir() . '/psl-fs-paths-' . uniqid(); Filesystem\create_directory($base . '/subdir', 0o755); $file = $base . '/subdir/file.txt'; Filesystem\create_file($file); // Resolve to absolute path (returns null if path does not exist) $real = Filesystem\canonicalize($base . '/subdir/../subdir/file.txt'); IO\write_line('Canonical: %s', $real ?? 'null'); IO\write_line('Basename: %s', Filesystem\get_basename($file)); // "file.txt" IO\write_line('Without ext: %s', Filesystem\get_basename($file, '.txt')); // "file" IO\write_line('Directory: %s', Filesystem\get_directory($file)); IO\write_line('Extension: %s', Filesystem\get_extension($file) ?? 'null'); // "txt" $noExt = $base . '/subdir/Makefile'; Filesystem\create_file($noExt); IO\write_line('No extension: %s', Filesystem\get_extension($noExt) ?? 'null'); // null Filesystem\delete_directory($base, recursive: true); ``` ## Reading Directory Contents ```php use Psl\Filesystem; use Psl\IO; $dir = sys_get_temp_dir() . '/psl-fs-readdir-' . uniqid(); Filesystem\create_directory($dir); Filesystem\create_file($dir . '/alpha.txt'); Filesystem\create_file($dir . '/beta.txt'); Filesystem\create_directory($dir . '/subdir'); $entries = Filesystem\read_directory($dir); // Returns a list of absolute path strings (excludes . and ..) foreach ($entries as $entry) { IO\write_line(' %s', Filesystem\get_basename($entry)); } Filesystem\delete_directory($dir, recursive: true); ``` ## Error Handling All operations throw specific exceptions on failure: ```php use Psl\Filesystem; use Psl\IO; $missing = sys_get_temp_dir() . '/psl-fs-missing-' . uniqid() . '.txt'; try { Filesystem\delete_file($missing); } catch (Filesystem\Exception\NotFoundException) { IO\write_line('Caught NotFoundException: file does not exist'); } catch (Filesystem\Exception\NotFileException) { IO\write_line('Caught NotFileException: path exists but is not a file'); } ``` See [src/Psl/Filesystem/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/filesystem/src/Psl/Filesystem/) for the full API. --- # IO The `IO` component provides handle-based I/O abstractions. Instead of reaching for global functions like `fread()` and `fwrite()`, you work with typed handle interfaces that make I/O composable, testable, and safe. ## Handle Interfaces Handles are defined as narrow interfaces, each describing a single capability: - **`ReadHandleInterface`** -- read bytes, check for EOF - **`WriteHandleInterface`** -- write bytes - **`SeekHandleInterface`** -- move the cursor position - **`CloseHandleInterface`** -- explicitly close the handle, check if closed via `isClosed()` A concrete handle implements whichever combination applies. For example, a file handle implements read, write, seek, and close, while a network socket implements read, write, and close but not seek. ### Buffered Interfaces Two higher-level interfaces extend the base read and write interfaces for handles that buffer data internally: - **`BufferedReadHandleInterface`** -- extends `ReadHandleInterface` with `readByte()`, `readLine()`, `readUntil()`, and `readUntilBounded()`. Implemented by `Reader`. - **`BufferedWriteHandleInterface`** -- extends `WriteHandleInterface` with `flush()` for explicitly flushing buffered output. Useful for decorators like compression handles that accumulate data before writing. ## Quick Output Convenience functions write directly to stdout or stderr: ```php use Psl\IO; $msg = 'file not found'; IO\write('Hello, %s!', 'world'); // stdout, no newline IO\write_line('Count: %d', 42); // stdout + newline IO\write_error('something went wrong'); // stderr IO\write_error_line('Error: %s', $msg); // stderr + newline ``` All output functions support `sprintf`-style formatting. ## Standard Handles Three functions return the process-level I/O handles: ```php use Psl\IO; $stdin = IO\input_handle(); // ReadHandleInterface (STDIN in CLI) $stdout = IO\output_handle(); // WriteHandleInterface (STDOUT in CLI) $stderr = IO\error_handle(); // WriteHandleInterface (STDERR in CLI, null otherwise) ``` In non-CLI SAPIs, `input_handle()` reads from `php://input` and `output_handle()` writes to `php://output`. `error_handle()` returns `null` outside CLI. ## MemoryHandle `MemoryHandle` is an in-memory buffer implementing read, write, seek, and close. It is useful for testing code that accepts handle interfaces without touching the filesystem or network. ```php use Psl\IO; $handle = new IO\MemoryHandle(); $handle->writeAll('Hello, World!'); $handle->seek(0); $handle->readAll(); // 'Hello, World!' $_ = $handle->getBuffer(); // 'Hello, World!' (always returns full buffer) // Use MemoryHandle as a test double for any WriteHandleInterface function render_report(IO\WriteHandleInterface $output): void { $output->writeAll("Report\n"); } $buffer = new IO\MemoryHandle(); render_report($buffer); Psl\invariant($buffer->getBuffer() === "Report\n", 'Expected buffer to contain "Report\n"'); ``` ## Reader `Reader` wraps any `ReadHandleInterface` and implements `BufferedReadHandleInterface`, providing buffered, higher-level reading methods: ```php use Psl\IO; $handle = new IO\MemoryHandle("line one\nline two\n\x00rest"); $reader = new IO\Reader($handle); $line = $reader->readLine(); // read until newline: "line one" $byte = $reader->readByte(); // read exactly one byte: "l" $chunk = $reader->readFixedSize(7); // read exactly 7 bytes: "ine two" $until = $reader->readUntil("\0"); // read until a delimiter (delimiter consumed but not returned): "\n" IO\write_line('Line: %s', $line ?? ''); IO\write_line('Byte: %s', $byte); IO\write_line('Chunk: %s', $chunk); IO\write_line('Until: %s', $until ?? ''); ``` ### Bounded Reads `Reader::readUntilBounded()` works like `readUntil()` but enforces a maximum byte limit. If the suffix is not found within `$max_bytes`, an `IO\Exception\OverflowException` is thrown. This prevents unbounded memory consumption when reading from untrusted sources -- for example, capping HTTP header lines to a safe size so a malicious client cannot exhaust memory by sending an endless line. ```php use Psl\IO; $handle = new IO\MemoryHandle("GET / HTTP/1.1\r\nHost: example.com\r\n\r\nbody"); $reader = new IO\Reader($handle); // Read the request line, capped at 8192 bytes $requestLine = $reader->readUntilBounded("\r\n", 8192); IO\write_line('Request line: %s', $requestLine ?? ''); // Read a header line, capped at 4096 bytes $header = $reader->readUntilBounded("\r\n", 4096); IO\write_line('Header: %s', $header ?? ''); // If the line exceeds the limit, OverflowException is thrown: $tinyHandle = new IO\MemoryHandle("this line is way too long\r\n"); $tinyReader = new IO\Reader($tinyHandle); try { $tinyReader->readUntilBounded("\r\n", 5); } catch (IO\Exception\OverflowException $e) { IO\write_line('Caught: %s', $e->getMessage()); } ``` ## Spool `IO\spool()` creates a handle that writes to memory until a threshold is reached (default 2MB), then transparently spools to a temporary file on disk. This is useful when buffering data of unknown size without risking excessive memory usage. ```php use Psl\IO; use Psl\Str; // Writes to memory until 2MB, then spools to a temporary file on disk $handle = IO\spool(); $handle->writeAll('Hello, World!'); $handle->seek(0); $handle->readAll(); // 'Hello, World!' $handle->close(); // Custom threshold: spool to disk after 64 bytes $small = IO\spool(maxMemory: 64); $small->writeAll(Str\repeat('x', 256)); // transparently written to disk $small->seek(0); $small->readAll(); // 256 bytes of 'x' $small->close(); ``` ## Pipes `IO\pipe()` creates a connected pair of handles: anything written to the write end can be read from the read end. ```php use Psl\IO; [$reader, $writer] = IO\pipe(); $writer->writeAll('hello'); $writer->close(); $result = $reader->readAll(); // 'hello' $reader->close(); IO\write_line('Read from pipe: %s', $result); ``` ## Copying and Streaming `IO\copy()` reads from one handle and writes to another until EOF. If the writer implements `BufferedWriteHandleInterface`, it is flushed after all data has been written: ```php use Psl\IO; $source = new IO\MemoryHandle('data to copy'); $dest = new IO\MemoryHandle(); IO\copy($source, $dest); IO\write_line('Copied: %s', $dest->getBuffer()); // 'data to copy' ``` For control over the read chunk size, use `IO\copy_chunked()`: ```php use Psl\IO; $source = new IO\MemoryHandle(str_repeat('x', 100_000)); $destination = new IO\MemoryHandle(); // Copy using 4 KB chunks instead of the default 8 KB $bytesCopied = IO\copy_chunked($source, $destination, 4096); $bytesCopied; // 100000 ``` Both functions have bidirectional variants -- `IO\copy_bidirectional()` and `IO\copy_bidirectional_chunked()` -- that copy data in both directions concurrently between two read-write handles. These are useful for building proxies and tunnels. `IO\streaming()` multiplexes reads from several stream handles concurrently, yielding chunks as they arrive. This is useful for reading interleaved process output: ```php use Psl\IO; // Create two pipes to simulate concurrent streams [$outReader, $outWriter] = IO\pipe(); [$errReader, $errWriter] = IO\pipe(); // Write data to both pipes $outWriter->writeAll('stdout output'); $outWriter->close(); $errWriter->writeAll('stderr output'); $errWriter->close(); foreach (IO\streaming(['out' => $outReader, 'err' => $errReader]) as $name => $chunk) { IO\write_line('[%s] %s', $name, $chunk); } $outReader->close(); $errReader->close(); ``` ## Handle Decorators PSL provides several decorator handles that wrap an existing handle and add behavior. ### IterableReadHandle `IterableReadHandle` wraps an `iterable` (array, generator, or any iterable) as a streaming `ReadHandleInterface`. The iterator is advanced lazily on each `read()` call, so the entire content is never buffered in memory. ```php use Psl\IO; // Wrap a generator as a streaming read handle $handle = new IO\IterableReadHandle( (function () { yield 'first chunk'; yield 'second chunk'; yield 'third chunk'; })(), ); $handle->read(); // 'first chunk' $handle->read(); // 'second chunk' $handle->read(); // 'third chunk' $handle->read(); // '' (EOF) $handle->reachedEndOfDataSource(); // true // Also works with arrays $handle = new IO\IterableReadHandle(['hello', ' ', 'world']); $handle->readAll(); // 'hello world' ``` ### ConcatReadHandle `ConcatReadHandle` reads from two handles in sequence. It delegates to the first handle until EOF, then transparently switches to the second. ```php use Psl\IO; // Concatenate two handles into a single stream $header = new IO\MemoryHandle("Header\n"); $body = new IO\MemoryHandle('Body content'); $combined = new IO\ConcatReadHandle($header, $body); $combined->readAll(); // "Header\nBody content" ``` ### TruncatedReadHandle `TruncatedReadHandle` reads up to N bytes from the underlying handle. Once the limit is reached, it silently reports EOF regardless of whether more data is available. Data beyond the limit is neither consumed nor validated. ```php use Psl\IO; // Read at most 5 bytes, silently discard the rest $inner = new IO\MemoryHandle('hello world'); $handle = new IO\TruncatedReadHandle($inner, 5); $handle->readAll(); // 'hello' $handle->reachedEndOfDataSource(); // true // The underlying handle still has data, but the truncated handle doesn't care $inner->tryRead(); // ' world' ``` ### BoundedReadHandle `BoundedReadHandle` reads up to N bytes from the underlying handle. Unlike `TruncatedReadHandle`, it peeks past the limit to check for overflow. If the underlying handle has more data, an `IO\Exception\RuntimeException` is thrown. Use this when exceeding the limit is a violation (e.g., HTTP response body size limits). ```php use Psl\IO; // Enforce a maximum of 5 bytes -- throw if the source has more $inner = new IO\MemoryHandle('hello'); $handle = new IO\BoundedReadHandle($inner, 5); $handle->readAll(); // 'hello' -- fits exactly, no error // When the source exceeds the limit, an exception is thrown $inner = new IO\MemoryHandle('hello world'); $handle = new IO\BoundedReadHandle($inner, 5); $handle->read(5); // 'hello' try { $handle->read(); // throws -- underlying handle has more data } catch (IO\Exception\RuntimeException $e) { $e->getMessage(); // 'Response body exceeded the configured limit of 5 bytes.' } ``` ### FixedLengthReadHandle `FixedLengthReadHandle` reads exactly N bytes from the underlying handle. If the handle reaches EOF before N bytes are consumed, an `IO\Exception\RuntimeException` is thrown. Use this when the data source promises a specific length (e.g., HTTP Content-Length bodies, fixed-size protocol frames). ```php use Psl\IO; // Read exactly 11 bytes $inner = new IO\MemoryHandle('hello world'); $handle = new IO\FixedLengthReadHandle($inner, 11); $handle->readAll(); // 'hello world' $handle->reachedEndOfDataSource(); // true // Premature EOF throws $inner = new IO\MemoryHandle('hi'); $handle = new IO\FixedLengthReadHandle($inner, 10); try { $handle->readAll(); // throws -- only 2 bytes available, expected 10 } catch (IO\Exception\RuntimeException $e) { $e->getMessage(); // 'Expected 10 bytes, but only 2 were available (premature EOF)' } ``` ### JoinedReadWriteHandle `JoinedReadWriteHandle` combines a `ReadHandleInterface` and a `WriteHandleInterface` into a single bidirectional handle. All read operations delegate to the reader, all write operations delegate to the writer. It implements `BufferedWriteHandleInterface`, so calling `flush()` delegates to the underlying writer if it also implements `BufferedWriteHandleInterface`. ```php use Psl\IO; // Join separate read and write handles into one bidirectional handle $reader = new IO\MemoryHandle('incoming data'); $writer = new IO\MemoryHandle(); $handle = new IO\JoinedReadWriteHandle($reader, $writer); $handle->readAll(); // 'incoming data' (from reader) $handle->writeAll('outgoing data'); // written to writer $writer->seek(0); $writer->readAll(); // 'outgoing data' ``` ### TeeWriteHandle `TeeWriteHandle` writes to two handles simultaneously. If the second handle is slower, data is buffered internally and drained on subsequent writes. It implements `BufferedWriteHandleInterface` -- call `flush()` to explicitly drain any pending data to the second handle. This is useful for mirroring output to a log, computing a hash while streaming, or duplicating data to two destinations. ```php use Psl\IO; // Write to two handles simultaneously -- useful for logging, hashing, or mirroring $primary = new IO\MemoryHandle(); $mirror = new IO\MemoryHandle(); $tee = new IO\TeeWriteHandle($primary, $mirror); $tee->writeAll('hello world'); $primary->seek(0); $primary->readAll(); // 'hello world' $mirror->seek(0); $mirror->readAll(); // 'hello world' ``` ### Sink Handles `SinkWriteHandle` discards all written data, like `/dev/null`. `SinkReadHandle` is a read handle that always reports EOF immediately -- unlike `new MemoryHandle('')` which only reports EOF after the first read attempt. `SinkReadWriteHandle` combines both: writes are discarded and reads always report EOF. All write sinks implement `BufferedWriteHandleInterface` with a no-op `flush()` for compatibility with code that expects flushable writers. These are useful as no-op handles in tests or when output must be consumed but can be discarded. ```php use Psl\IO; // SinkWriteHandle discards everything -- like /dev/null $sink = new IO\SinkWriteHandle(); $sink->writeAll('this goes nowhere'); $sink->writeAll('neither does this'); // SinkReadWriteHandle also supports reads, but always reports EOF $sink = new IO\SinkReadWriteHandle(); $sink->writeAll('discarded'); $sink->read(); // '' (always empty) $sink->reachedEndOfDataSource(); // true (always EOF) ``` See [src/Psl/IO/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/io/src/Psl/IO/) for the full API. --- # Process The `Process` component provides a typed, non-blocking API for spawning and managing child processes, built on top of PSL's IO system. It serves as an alternative to `proc_*`, `symfony/process`, and `amphp/process`, with an API inspired by Rust's `std::process` module adapted to PSL conventions. ## Usage ```php use Psl\IO; use Psl\Process\Command; // Run a command and collect its output $output = Command::create('echo')->withArguments(['Hello', 'from', 'process'])->output(); if ($output->status->isSuccessful()) { IO\write_line('Output: %s', trim($output->stdout)); } ``` ### Command Construction `Command` is an immutable builder. All `with*()` methods return a new instance. - `Command::create(string $program)` -- executes a program directly, bypassing the shell. Prevents shell injection and is the recommended approach. - `Command::shell(string $command)` -- interpreted by the system shell (`/bin/sh -c` on Unix, `cmd.exe` on Windows). Allows pipes, globbing, and variable expansion. ### Stdio Configuration `Stdio` controls what happens with each standard I/O stream: - `Stdio::piped()` -- pipe between parent and child (default for stdout/stderr) - `Stdio::inherit()` -- child inherits the parent's descriptor - `Stdio::null()` -- attach to `/dev/null` (default for stdin) - `Stdio::tty()` -- connect directly to the terminal, useful for interactive programs (Unix only) - `Stdio::fromStreamHandle($handle)` -- use an existing stream handle ## Examples ### Collect Output ```php use Psl\IO; use Psl\Process\Command; $output = Command::create('php')->withArguments(['-r', 'echo "hello world";'])->output(); IO\write_line('stdout: %s', $output->stdout); // "hello world" IO\write_line('stderr: %s', $output->stderr); // "" ``` ### Check Exit Status ```php use Psl\IO; use Psl\Process\Command; $status = Command::create('php')->withArguments(['-r', 'exit(42);'])->status(); IO\write_line('Successful: %s', $status->isSuccessful() ? 'true' : 'false'); // false IO\write_line('Exit code: %d', $status->getCode()); // 42 ``` ### Write to Stdin ```php use Psl\IO; use Psl\Process\Command; use Psl\Process\Stdio; $child = Command::create('cat')->withStdin(Stdio::piped())->spawn(); $child->getStdin()->writeAll("hello\n"); $child->getStdin()->close(); $output = $child->getStdout()->readAll(); $child->wait(); IO\write_line('Read from child: %s', trim($output)); ``` ### Shell Commands ```php use Psl\IO; use Psl\Process\Command; $output = Command::shell('echo hello && echo world')->output(); IO\write_line('stdout: %s', $output->stdout); // "hello\nworld\n" ``` ### Cancellation ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; use Psl\Process\Command; try { Command::create('sleep') ->withArgument('60') ->status(new Async\TimeoutCancellationToken(Duration::seconds(1))); } catch (Async\Exception\CancelledException) { IO\write_line('Process was killed after timeout (as expected)'); } ``` ### Signals ```php use Psl\IO; use Psl\Process\Command; use Psl\Process\Signal; use Psl\Process\Stdio; $child = Command::create('sleep')->withArgument('60')->withStdout(Stdio::null())->withStderr(Stdio::null())->spawn(); // Allow the child process to start before signaling usleep(50_000); $child->signal(Signal::Terminate); $status = $child->wait(); IO\write_line('Has been signaled: %s', $status->hasBeenSignaled() ? 'true' : 'false'); $signal = $status->getTerminationSignal(); IO\write_line('Termination signal: %s', $signal !== null ? $signal->name : 'none'); ``` ### Environment Variables ```php use Psl\IO; use Psl\Process\Command; $output = Command::create('php') ->withArguments(['-r', 'echo getenv("APP_ENV");']) ->withEnvironmentVariable('APP_ENV', 'production') ->output(); IO\write_line('APP_ENV: %s', $output->stdout); // "production" ``` ### Non-Blocking Poll ```php use Psl\IO; use Psl\Process\Command; use Psl\Process\Stdio; $child = Command::create('sleep')->withArgument('0.1')->withStdout(Stdio::null())->withStderr(Stdio::null())->spawn(); $status = $child->tryWait(); // null (still running) IO\write_line('Still running: %s', $status === null ? 'true' : 'false'); // ... do other work ... $status = $child->wait(); // blocks until done IO\write_line('Finished with code: %d', $status->getCode()); ``` See [src/Psl/Process/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/process/src/Psl/Process/) for the full API. --- # Shell The `Shell` component provides a single function, `Shell\execute()`, for running external commands and capturing their output. It handles argument escaping, error output management, and proper exception handling for failed commands. For more advanced process management (streaming I/O, signals, non-blocking operation), see the `Process` component. `Shell` is the simpler choice when you just need to run a command and get the result. ## Basic Usage ```php use Psl\IO; use Psl\Shell; // Run a command and capture stdout $output = Shell\execute('echo', ['Hello from shell']); IO\write_line('Output: %s', $output); // With a specific working directory $output = Shell\execute('ls', ['-la'], __DIR__); IO\write_line('Directory listing:\n%s', $output); // With environment variables $output = Shell\execute( 'php', ['-r', 'echo getenv("APP_ENV");'], environment: [ 'APP_ENV' => 'production', 'DEBUG' => '0', ], ); IO\write_line('Env value: %s', $output); ``` ## Handling Error Output By default, stderr is discarded. The `ErrorOutputBehavior` enum controls how stderr is handled relative to stdout: ```php use Psl\IO; use Psl\Shell; use Psl\Shell\ErrorOutputBehavior; // Discard stderr (default) $stdout = Shell\execute( 'php', ['-r', 'echo "out"; fwrite(STDERR, "err");'], errorOutputBehavior: ErrorOutputBehavior::Discard, ); IO\write_line('Discard: stdout=%s', $stdout); // Append stderr after stdout $combined = Shell\execute( 'php', ['-r', 'echo "out"; fwrite(STDERR, "err");'], errorOutputBehavior: ErrorOutputBehavior::Append, ); IO\write_line('Append: %s', $combined); // Prepend stderr before stdout $combined = Shell\execute( 'php', ['-r', 'echo "out"; fwrite(STDERR, "err");'], errorOutputBehavior: ErrorOutputBehavior::Prepend, ); IO\write_line('Prepend: %s', $combined); // Return only stderr, discard stdout $stderr = Shell\execute( 'php', ['-r', 'echo "out"; fwrite(STDERR, "err");'], errorOutputBehavior: ErrorOutputBehavior::Replace, ); IO\write_line('Replace: %s', $stderr); // Pack both streams for later separation $packed = Shell\execute( 'php', ['-r', 'echo "out"; fwrite(STDERR, "err");'], errorOutputBehavior: ErrorOutputBehavior::Packed, ); [$stdout, $stderr] = Shell\unpack($packed); IO\write_line('Packed: stdout=%s, stderr=%s', $stdout, $stderr); ``` ## Cancellation Pass a `CancellationTokenInterface` to cancel a running command. Use `TimeoutCancellationToken` to enforce a time limit: ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; use Psl\Shell; try { $output = Shell\execute('sleep', ['10'], cancellation: new Async\TimeoutCancellationToken(Duration::seconds(1))); } catch (Async\Exception\CancelledException) { IO\write_line('Command exceeded the time limit (as expected)'); } ``` ## Error Handling `execute()` throws specific exceptions depending on what went wrong: - **`FailedExecutionException`** -- the command exited with a non-zero status. Provides `getCommand()`, `getOutput()`, and `getErrorOutput()` for inspection. - **`PossibleAttackException`** -- the command or an argument contains a NULL byte, indicating a potential injection attack. - **`CancelledException`** -- the command was cancelled via the cancellation token. - **`RuntimeException`** -- the working directory does not exist, or the process could not be created. ```php use Psl\IO; use Psl\Shell; try { $output = Shell\execute('php', ['-r', 'exit(1);']); } catch (Shell\Exception\FailedExecutionException $e) { IO\write_line('Command failed: %s', $e->getCommand()); IO\write_line('Exit code: %d', $e->getCode()); IO\write_line('Stderr: %s', $e->getErrorOutput()); } ``` See [src/Psl/Shell/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/shell/src/Psl/Shell/) for the full API. --- # CIDR The `CIDR` component provides utilities for working with CIDR (Classless Inter-Domain Routing) notation. It supports both IPv4 and IPv6 addresses. IPv4 addresses are internally normalized to IPv4-mapped IPv6 for unified comparison, so mixed-family matching works transparently. ## Usage ```php use Psl\CIDR; $block = new CIDR\Block('192.168.1.0/24'); $block->contains('192.168.1.100'); // true $block->contains('192.168.2.1'); // false ``` `Block::contains()` accepts either a string or an `IP\Address` object: ```php use Psl\CIDR; use Psl\IP\Address; $block = new CIDR\Block('192.168.1.0/24'); // Using a string $block->contains('192.168.1.100'); // true // Using an Address object $addr = Address::parse('192.168.1.100'); $block->contains($addr); // true ``` ## Examples ### IPv4 ```php use Psl\CIDR; $private = new CIDR\Block('10.0.0.0/8'); $private->contains('10.1.2.3'); // true $private->contains('172.16.0.1'); // false ``` ### IPv6 ```php use Psl\CIDR; $loopback = new CIDR\Block('::1/128'); $loopback->contains('::1'); // true ``` ### Single Host ```php use Psl\CIDR; $exact = new CIDR\Block('192.168.1.1/32'); $exact->contains('192.168.1.1'); // true $exact->contains('192.168.1.2'); // false ``` See [src/Psl/CIDR/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/cidr/src/Psl/CIDR/) for the full API. --- # IP The `IP` component provides an immutable, binary-backed value object for working with IPv4 and IPv6 addresses. It supports parsing, formatting, classification, comparison, and reverse DNS lookups. `Address` implements `Stringable`, `Comparable`, and `Equable`. ## Usage ```php use Psl\IP\Address; $addr = Address::parse('192.168.1.10'); $addr->family; // Family::V4 $addr->toString(); // '192.168.1.10' $addr->isPrivate(); // true $addr->isGlobalUnicast(); // false ``` ## Parsing There are three ways to create an `Address`: - **`Address::v4()`** -- parse a dotted-decimal IPv4 address. - **`Address::v6()`** -- parse a colon-hex IPv6 address. - **`Address::parse()`** -- auto-detect the family. You can also create an address from raw binary bytes using `Address::fromBytes()`. ```php use Psl\IP\Address; // Parse with explicit family $v4 = Address::v4('192.168.1.1'); $v6 = Address::v6('2001:db8::1'); // Auto-detect family $auto = Address::parse('::1'); $auto->family; // Family::V6 // Create from raw binary bytes $fromBytes = Address::fromBytes("\xc0\xa8\x01\x01"); $fromBytes->toString(); // '192.168.1.1' ``` ## Classification `Address` provides methods to classify addresses into well-known categories: | Method | IPv4 Range | IPv6 Range | |---|---|---| | `isLoopback()` | `127.0.0.0/8` | `::1` | | `isPrivate()` | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | `fc00::/7` | | `isLinkLocal()` | `169.254.0.0/16` | `fe80::/10` | | `isMulticast()` | `224.0.0.0/4` | `ff00::/8` | | `isUnspecified()` | `0.0.0.0` | `::` | | `isDocumentation()` | `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24` | `2001:db8::/32` | | `isGlobalUnicast()` | Everything else | Everything else | ```php use Psl\IP\Address; $loopback = Address::v4('127.0.0.1'); $loopback->isLoopback(); // true $private = Address::v4('10.0.0.1'); $private->isPrivate(); // true $public = Address::v4('8.8.8.8'); $public->isGlobalUnicast(); // true $public->isPrivate(); // false $doc = Address::v6('2001:db8::1'); $doc->isDocumentation(); // true $doc->isGlobalUnicast(); // false ``` ## Formatting ```php use Psl\IP\Address; $v6 = Address::v6('2001:db8::1'); // Compressed (RFC 5952) $v6->toString(); // '2001:db8::1' // Fully expanded $v6->toExpandedString(); // '2001:0db8:0000:0000:0000:0000:0000:0001' // Raw binary bytes $v6->toBytes(); // 16-byte binary string // Stringable echo (string) $v6; // '2001:db8::1' ``` ## Reverse DNS ```php use Psl\IP\Address; $v4 = Address::v4('192.168.1.10'); $v4->toArpaName(); // '10.1.168.192.in-addr.arpa' $v6 = Address::v6('2001:db8::1'); $v6->toArpaName(); // '1.0.0.0.0.0.0.0...8.b.d.0.1.0.0.2.ip6.arpa' ``` ## Comparison `Address` implements `Comparable` and `Equable`, so addresses can be compared and sorted. ```php use Psl\IP\Address; $a = Address::v4('10.0.0.1'); $b = Address::v4('10.0.0.2'); $a->equals($b); // false $a->compare($b); // Psl\Comparison\Order::Less $b->compare($a); // Psl\Comparison\Order::Greater $c = Address::v6('2001:db8::1'); $d = Address::v6('2001:0db8:0000:0000:0000:0000:0000:0001'); $c->equals($d); // true (same bytes, different notation) ``` ## CIDR Integration `Address` objects can be passed directly to `CIDR\Block::contains()`: ```php use Psl\CIDR; use Psl\IP\Address; $block = new CIDR\Block('192.168.1.0/24'); // Pass a string $block->contains('192.168.1.100'); // true // Or pass an Address object $addr = Address::v4('192.168.1.100'); $block->contains($addr); // true ``` ## Family The `Family` enum represents the IP address family, with values `V4` (4) and `V6` (16) corresponding to their byte sizes. It also supports conversion to and from IANA address family numbers (RFC 7871). ```php use Psl\IP\Family; $family = Family::V4; $family->value; // 4 (byte size) $family->ianaFamily(); // 1 $fromIana = Family::fromIanaFamily(2); $fromIana === Family::V6; // true ``` See [src/Psl/IP/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/ip/src/Psl/IP/) for the full API. --- # IRI The `IRI` component provides RFC 3987 compliant Internationalized Resource Identifier parsing with full Unicode support, Punycode encoding/decoding, and IDNA 2008 domain name processing. ## Parsing Parse IRI strings containing Unicode characters: ```php use Psl\IO; use Psl\IRI; $iri = IRI\parse('https://münchen.de/straße?q=ünited#§ion'); // "https" IO\write_line('%s', $iri->scheme ?? ''); // "münchen.de" IO\write_line('%s', $iri->authority?->host?->toString() ?? ''); // "/straße" IO\write_line('%s', $iri->path); // "q=ünited" IO\write_line('%s', $iri->query ?? ''); // "§ion" IO\write_line('%s', $iri->fragment ?? ''); // "https://münchen.de/straße?q=ünited#§ion" IO\write_line('%s', $iri->toString()); ``` Input is NFC-normalized and validated against RFC 3987 character ranges. Private-use characters are only permitted in the query component. ## Converting to URI Convert an IRI to an ASCII-only RFC 3986 URI. International domain names are Punycode-encoded and Unicode path/query/fragment characters are percent-encoded: ```php use Psl\IO; use Psl\IRI; $iri = IRI\parse('https://münchen.de/straße'); $uri = $iri->toURI(); // "https://xn--mnchen-3ya.de/stra%C3%9Fe" IO\write_line('%s', $uri->toString()); ``` ## Converting from URI Reverse the process - decode a URI back to an IRI with Unicode characters restored: ```php use Psl\IO; use Psl\IRI; use Psl\URI; $uri = URI\parse('https://xn--mnchen-3ya.de/stra%C3%9Fe'); $iri = IRI\from_uri($uri); // "https://münchen.de/straße" IO\write_line('%s', $iri->toString()); ``` ## Standards | RFC | Title | |-----|-------| | [RFC 3987](https://datatracker.ietf.org/doc/html/rfc3987) | Internationalized Resource Identifiers (IRIs) | | [RFC 3492](https://datatracker.ietf.org/doc/html/rfc3492) | Punycode: Bootstring Encoding for IDNA | | [RFC 5891](https://datatracker.ietf.org/doc/html/rfc5891) | IDNA 2008: Protocol | | [RFC 5892](https://datatracker.ietf.org/doc/html/rfc5892) | IDNA 2008: Unicode Code Points | See [src/Psl/IRI/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/iri/src/Psl/IRI/) for the full API. --- # URI The `URI` component provides RFC 3986 compliant parsing, normalization, reference resolution, and RFC 6570 URI Template expansion. ## Parsing Parse any URI string into a structured, normalized `URI` object: ```php use Psl\IO; use Psl\URI; $uri = URI\parse('https://Example.COM:443/foo/../bar?q=1#frag'); // "https" IO\write_line('%s', $uri->scheme ?? ''); // "example.com" - lowercased IO\write_line('%s', $uri->authority?->host?->toString() ?? ''); // 443 IO\write_line('%d', $uri->authority->port ?? 0); // "/bar" - dot segments removed IO\write_line('%s', $uri->path); // "q=1" IO\write_line('%s', $uri->query ?? ''); // "frag" IO\write_line('%s', $uri->fragment ?? ''); // "https://example.com:443/bar?q=1#frag" IO\write_line('%s', $uri->toString()); ``` Normalization is applied eagerly: scheme and host are lowercased, percent-encoding is normalized (unreserved characters decoded, hex digits uppercased), and dot segments (`/../`, `/./`) are removed. ## Reference Resolution Resolve relative references against a base URI per RFC 3986 Section 5: ```php use Psl\IO; use Psl\URI; $base = URI\parse('https://example.com/a/b/c'); // "https://example.com/a/d" IO\write_line('%s', URI\resolve($base, URI\parse('../d'))->toString()); // "https://other/x" IO\write_line('%s', URI\resolve($base, URI\parse('//other/x'))->toString()); // "https://example.com/a/b/c?q=1" IO\write_line('%s', URI\resolve($base, URI\parse('?q=1'))->toString()); ``` ## URI Templates Parse and expand RFC 6570 URI Templates (Levels 1–4): ```php use Psl\IO; use Psl\URI\Template; $template = Template\parse('https://api.example.com/users/{id}/repos{?sort,page}'); $uri = $template->expand([ 'id' => '42', 'sort' => 'stars', 'page' => '2', ]); // "https://api.example.com/users/42/repos?sort=stars&page=2" IO\write_line('%s', $uri->toString()); ``` All operators are supported: simple `{var}`, reserved `{+var}`, fragment `{#var}`, label `{.var}`, path `{/var}`, parameter `{;var}`, query `{?var}`, and continuation `{&var}`. Modifiers include prefix `{var:3}` and explode `{var*}`. ## Authority & Hosts The authority component is structured into user info, host, and port. Hosts are typed - either an IP address or a registered name: ```php use Psl\IO; use Psl\URI; $uri = URI\parse('ssh://git@[::1%25eth0]:22/repo.git'); $authority = $uri->authority; Psl\invariant($authority !== null, 'Invalid URI authority.'); // "git" IO\write_line('%s', $authority->userInfo ?? ''); // 22 IO\write_line('%d', $authority->port ?? 0); // "[::1%25eth0]" IO\write_line('%s', $authority->host->toString()); ``` IPv6 addresses use RFC 5952 canonical form and support RFC 6874 zone identifiers. ## Standards | RFC | Title | |-----|-------| | [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) | Uniform Resource Identifier (URI): Generic Syntax | | [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) | URI Template (Levels 1–4) | | [RFC 6874](https://datatracker.ietf.org/doc/html/rfc6874) | IPv6 Zone Identifiers in URIs | | [RFC 5952](https://datatracker.ietf.org/doc/html/rfc5952) | IPv6 Address Text Representation | See [src/Psl/URI/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/uri/src/Psl/URI/) for the full API. --- # URL The `URL` component provides a strict URL type that guarantees a scheme and authority are always present. It builds on top of the `URI` and `IRI` packages, adding validation and default port stripping. ## Parsing Parse a URL string with strict validation; scheme and authority are required: ```php use Psl\IO; use Psl\URL; $url = URL\parse('https://example.com:443/path?q=1#frag'); // "https" IO\write_line('%s', $url->scheme); // "example.com" IO\write_line('%s', $url->authority->host->toString()); // null - default port stripped IO\write_line('%s', $url->authority->port === null ? 'null' : (string) $url->authority->port); // "/path" IO\write_line('%s', $url->path); // "q=1" IO\write_line('%s', $url->query ?? ''); // "frag" IO\write_line('%s', $url->fragment ?? ''); // "https://example.com/path?q=1#frag" IO\write_line('%s', $url->toString()); ``` Default ports are automatically stripped for known schemes (HTTP 80, HTTPS 443, FTP 21, SSH 22, and many more). ## From URI Convert a parsed `URI` to a `URL` with validation: ```php use Psl\IO; use Psl\URI; use Psl\URL; $uri = URI\parse('https://example.com/path'); $url = URL\from_uri($uri); // "https://example.com/path" IO\write_line('%s', $url->toString()); ``` Throws `InvalidURLException` if the URI is missing a scheme, authority, or has a rootless path. ## From IRI Convert an internationalized IRI directly to a URL, applying Punycode encoding and percent-encoding in one step: ```php use Psl\IO; use Psl\IRI; use Psl\URL; $iri = IRI\parse('https://münchen.de/straße'); $url = URL\from_iri($iri); // "https://xn--mnchen-3ya.de/stra%C3%9Fe" IO\write_line('%s', $url->toString()); ``` ## Converting Back to URI A `URL` can be converted back to a `URI` for use with the full URI API: ```php use Psl\IO; use Psl\URL; $url = URL\parse('https://example.com/path'); $uri = $url->toURI(); // "https://example.com/path" IO\write_line('%s', $uri->toString()); ``` See [src/Psl/URL/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/url/src/Psl/URL/) for the full API. --- # Network The `Network` component defines the shared interfaces and types used by all networking components (`TCP`, `Unix`, `TLS`, `UDP`). It provides the abstraction layer that protocol-specific components build on top of, ensuring a consistent API across transport types. ## Core Concepts ### Address `Address` is an immutable value object representing a network address. It carries a scheme (TCP, UDP, or Unix), a host, and an optional port. ```php use Psl\Network\Address; $tcp = Address::tcp('127.0.0.1', 8080); $udp = Address::udp('0.0.0.0', 9999); $unix = Address::unix('/tmp/my-app.sock'); echo $tcp->toString(); // "tcp://127.0.0.1:8080" ``` ### StreamInterface `StreamInterface` is the core type for connected network streams (TCP, Unix, TLS). It is a bidirectional stream that supports reading, writing, peeking, and shutting down the write side of a connection. ```php use Psl\Async; use Psl\TCP; // Demonstrate StreamInterface usage with a TCP server and client $listener = TCP\listen('127.0.0.1'); Async\concurrently([ 'server' => static function () use ($listener): void { $stream = $listener->accept(); // Read and write data $stream->writeAll('hello'); $stream->shutdown(); // signal EOF to the remote peer $_ = $stream->readAll(); // Peek at incoming data without consuming it // (only works before data is consumed) // Inspect addresses $_ = $stream->getLocalAddress(); $_ = $stream->getPeerAddress(); $stream->close(); $listener->close(); }, 'client' => static function () use ($listener): void { $address = $listener->getLocalAddress(); $client = TCP\connect($address->host, $address->port ?? 0); $data = $client->readAll(); $client->writeAll('reply'); $client->close(); }, ]); ``` ### ListenerInterface `ListenerInterface` accepts incoming connections and returns `StreamInterface` instances. Each protocol component (TCP, Unix) provides its own listener that extends this interface. ```php $connection = $listener->accept(); // blocks until a connection arrives ``` ### Socket Pairs `socket_pair()` creates two connected bidirectional streams. Data written to one end can be read from the other, which is useful for inter-process communication and testing. ```php use Psl\Network; [$a, $b] = Network\socket_pair(); $a->writeAll('ping'); $a->shutdown(); echo $b->readAll(); // "ping" ``` ## Interface Hierarchy ``` IO\CloseHandleInterface \-- SocketInterface |-- ListenerInterface | |-- TCP\ListenerInterface | \-- Unix\ListenerInterface \-- StreamInterface (+ IO\ReadHandleInterface, IO\WriteHandleInterface) |-- TCP\StreamInterface |-- Unix\StreamInterface \-- TLS\StreamInterface ``` See [src/Psl/Network/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/network/src/Psl/Network/) for the full API. --- # Socks The `Socks` component provides a SOCKS5 proxy connector that tunnels TCP connections through a SOCKS5 proxy server (RFC 1928). It implements `TCP\ConnectorInterface`, so it can be used anywhere a TCP connector is expected -- enabling transparent proxy tunneling for any code that accepts a connector. ## Usage ```php use Psl\Socks; use Psl\TCP; // Note: This example requires a running SOCKS5 proxy server. $connector = new Socks\Connector(new TCP\Connector(), new Socks\Configuration('proxy.example.com', 1080)); $stream = $connector->connect('target.example.com', 80); $stream->writeAll("GET / HTTP/1.0\r\nHost: target.example.com\r\n\r\n"); $stream->shutdown(); $response = $stream->readAll(); $stream->close(); ``` With username/password authentication (RFC 1929): ```php use Psl\Socks; use Psl\TCP; // Note: This example requires a running SOCKS5 proxy server with authentication. $connector = new Socks\Connector( new TCP\Connector(), new Socks\Configuration('proxy.example.com', 1080, 'user', 'pass'), ); ``` ## Examples ### Tunneling TLS Through a Proxy ```php use Psl\Socks; use Psl\TCP; use Psl\TLS; // Note: This example requires a running SOCKS5 proxy server. $proxy = new Socks\Connector(new TCP\Connector(), new Socks\Configuration('proxy.example.com', 1080, 'user', 'pass')); $stream = $proxy->connect('api.example.com', 443); $tls = TLS\Connector::default()->connect($stream, 'api.example.com'); $tls->writeAll("GET /data HTTP/1.1\r\nHost: api.example.com\r\n\r\n"); ``` ### With Connection Pooling ```php use Psl\Socks; use Psl\TCP; // Note: This example requires a running SOCKS5 proxy server. $proxy = new Socks\Connector(new TCP\Connector(), new Socks\Configuration('proxy.example.com', 1080)); $pool = new TCP\SocketPool(connector: $proxy); $stream = $pool->checkout('api.example.com', 80); // ... use stream ... $pool->checkin($stream); ``` ### With Retry ```php use Psl\DateTime\Duration; use Psl\Socks; use Psl\TCP; // Note: This example requires a running SOCKS5 proxy server. $proxy = new Socks\Connector( new TCP\RetryConnector(new TCP\Connector(), maxAttempts: 3, backoff: Duration::seconds(1)), new Socks\Configuration('proxy.example.com', 1080), ); $stream = $proxy->connect('target.example.com', 80); ``` See [src/Psl/Socks/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/socks/src/Psl/Socks/) for the full API. --- # TCP The `TCP` component provides a non-blocking API for TCP client and server connections, built on top of PSL's IO and Network abstractions. ## Usage ```php use Psl\Async; use Psl\TCP; $listener = TCP\listen('127.0.0.1'); Async\concurrently([ 'server' => static function () use ($listener): void { $connection = $listener->accept(); $request = $connection->readAll(); $connection->writeAll("echo: {$request}"); $connection->close(); $listener->close(); }, 'client' => static function () use ($listener): void { $address = $listener->getLocalAddress(); $client = TCP\connect($address->host, $address->port ?? 0); $client->writeAll('hello'); $client->shutdown(); $response = $client->readAll(); $client->close(); }, ]); ``` ## Design ### ConnectorInterface All TCP connectors implement `ConnectorInterface`, making them interchangeable and composable. The built-in connectors are: - **`Connector`** -- the default connector wrapping `TCP\connect()`. - **`RetryConnector`** -- wraps any connector with exponential backoff retry on failure. - **`StaticConnector`** -- redirects all connections to a fixed host and port (useful for testing). ### SocketPool `SocketPool` reuses idle TCP connections. Checked-in connections stay alive for a configurable idle timeout before being closed. ```php use Psl\Async; use Psl\TCP; $listener = TCP\listen('127.0.0.1'); $address = $listener->getLocalAddress(); Async\concurrently([ 'server' => static function () use ($listener): void { $connection = $listener->accept(); // Keep connection open until client is done $connection->readAll(); $connection->close(); $listener->close(); }, 'client' => static function () use ($address): void { $pool = new TCP\SocketPool(); $stream = $pool->checkout($address->host, $address->port ?? 0); // ... use stream ... $pool->checkin($stream); // Later -- reuses the same connection $stream = $pool->checkout($address->host, $address->port ?? 0); $pool->clear($stream); $pool->close(); }, ]); ``` ### Configuration `TCP\listen()` and `TCP\connect()` accept configuration objects that control socket behavior: - **`ListenConfiguration`** -- noDelay, reuseAddress, reusePort, backlog (default 512), idleConnections (default 256), bindTo - **`ConnectConfiguration`** -- noDelay, bindTo The `bindTo` option on both configuration objects allows binding to a specific local address before connecting or listening. This is useful for selecting a particular network interface or source IP: ```php use Psl\Async; use Psl\TCP; // Connect from a specific local address. // The bindTo option binds the socket to a local IP before connecting, // useful for selecting a particular network interface or source IP. $listener = TCP\listen('127.0.0.1'); $port = $listener->getLocalAddress()->port ?? 0; Async\concurrently([ 'server' => static function () use ($listener): void { $connection = $listener->accept(); $connection->writeAll('hello'); $connection->close(); $listener->close(); }, 'client' => static function () use ($port): void { $stream = TCP\connect('127.0.0.1', $port, new TCP\ConnectConfiguration(bindTo: '127.0.0.1:0')); $local = $stream->getLocalAddress(); Psl\invariant($local->host === '127.0.0.1', 'Host address is wrong'); $data = $stream->readAll(); $stream->close(); }, ]); ``` All configuration objects are immutable and provide `with*` builder methods for fluent configuration: ```php use Psl\IO; use Psl\TCP; // Configuration objects are immutable with fluent with* builder methods $config = TCP\ListenConfiguration::default()->withNoDelay(true)->withBacklog(2048)->withIdleConnections(128); $listener = TCP\listen('127.0.0.1', 0, $config); $address = $listener->getLocalAddress(); IO\write_line('Listening on %s:%d', $address->host, $address->port ?? 0); $listener->close(); // ConnectConfiguration works the same way $connectConfig = TCP\ConnectConfiguration::default()->withNoDelay(true); IO\write_line('Connect config noDelay: %s', $connectConfig->noDelay ? 'true' : 'false'); ``` ```php use Psl\TCP; // Default backlog of 512 $listener = TCP\listen('127.0.0.1', 8080); // High-throughput server with larger backlog $listener = TCP\listen('127.0.0.1', 8080, new TCP\ListenConfiguration(backlog: 4096)); ``` ### Low-Level Socket (Deprecated) > **Deprecated**: `TCP\Socket` is deprecated in favor of the `bindTo` option on `ConnectConfiguration` and `ListenConfiguration`. It will be removed in PSL 7.0. `Socket` previously provided fine-grained control over socket creation via a bind-then-connect/listen pattern. This is now replaced by the `bindTo` configuration option. See the example below for the migration pattern: ```php use Psl\Async; use Psl\TCP; // DEPRECATED: TCP\Socket is deprecated. Use the bindTo option instead. // // Before (deprecated): // $socket = TCP\Socket::createV4(); // $socket->bind('127.0.0.1', 0); // $stream = $socket->connect('example.com', 443); // // After: // $stream = TCP\connect('example.com', 443, new TCP\ConnectConfiguration( // bindTo: '127.0.0.1:0', // )); // The new way: use bindTo on ConnectConfiguration or ListenConfiguration. $listener = TCP\listen('127.0.0.1', 0, new TCP\ListenConfiguration(reuseAddress: true, noDelay: true)); Async\concurrently([ 'server' => static function () use ($listener): void { $connection = $listener->accept(); $data = $connection->readAll(); $connection->writeAll($data); $connection->close(); $listener->close(); }, 'client' => static function () use ($listener): void { $address = $listener->getLocalAddress(); $client = TCP\connect($address->host, $address->port ?? 0, new TCP\ConnectConfiguration(bindTo: '127.0.0.1:0')); $client->writeAll('test'); $client->shutdown(); $response = $client->readAll(); $client->close(); }, ]); ``` ## Examples ### Echo Server ```php use Psl\Async; use Psl\TCP; $listener = TCP\listen('127.0.0.1'); Async\concurrently([ 'server' => static function () use ($listener): void { echo "Listening on {$listener->getLocalAddress()->toString()}\n"; // Accept one connection then shut down $connection = $listener->accept(); Async\run(static function () use ($connection): void { $data = $connection->readAll(); $connection->writeAll($data); $connection->close(); })->await(); $listener->close(); }, 'client' => static function () use ($listener): void { $address = $listener->getLocalAddress(); $client = TCP\connect($address->host, $address->port ?? 0); $client->writeAll('hello from client'); $client->shutdown(); $response = $client->readAll(); echo "Got: {$response}\n"; $client->close(); }, ]); ``` ### Client with Cancellation ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\TCP; $listener = TCP\listen('127.0.0.1'); Async\concurrently([ 'server' => static function () use ($listener): void { $connection = $listener->accept(); $_ = $connection->readAll(); $connection->writeAll("HTTP/1.0 200 OK\r\n\r\nHello"); $connection->close(); $listener->close(); }, 'client' => static function () use ($listener): void { $address = $listener->getLocalAddress(); $client = TCP\connect( $address->host, $address->port ?? 0, cancellation: new Async\TimeoutCancellationToken(Duration::seconds(5)), ); $client->writeAll("GET / HTTP/1.0\r\nHost: localhost\r\n\r\n"); $client->shutdown(); $_ = $client->readAll(); $client->close(); }, ]); ``` ### Retry with Backoff ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\TCP; // Create a retry connector with exponential backoff $connector = new TCP\RetryConnector( new TCP\Connector(new TCP\ConnectConfiguration(noDelay: true)), maxAttempts: 5, backoff: Duration::milliseconds(500), ); // Demonstrate by connecting to a local server $listener = TCP\listen('127.0.0.1'); Async\concurrently([ 'server' => static function () use ($listener): void { $connection = $listener->accept(); $connection->writeAll('connected'); $connection->close(); $listener->close(); }, 'client' => static function () use ($connector, $listener): void { $address = $listener->getLocalAddress(); $stream = $connector->connect($address->host, $address->port ?? 0); $data = $stream->readAll(); $stream->close(); }, ]); ``` ### Cancellable Accept `ListenerInterface::accept()` accepts a `CancellationTokenInterface`, allowing you to cancel waiting for connections, for example during a graceful shutdown: ```php use Psl\Async; use Psl\IO; use Psl\TCP; $listener = TCP\listen('127.0.0.1', 0); $token = new Async\SignalCancellationToken(); // Simulate shutdown after 50ms Async\Scheduler::delay(Psl\DateTime\Duration::milliseconds(50), static fn(string $_) => $token->cancel()); while (true) { try { $conn = $listener->accept($token); } catch (Async\Exception\CancelledException) { IO\write_line('Graceful shutdown'); break; } } $listener->close(); ``` ### Restricted Listener `RestrictedListener` wraps any `ListenerInterface` and restricts connections to a set of allowed `IP\Address` and `CIDR\Block` entries. Rejected connections are closed silently. ```php use Psl\Async; use Psl\CIDR; use Psl\IO; use Psl\IP; use Psl\TCP; // Only allow connections from localhost and the 10.0.0.0/8 private range $inner = TCP\listen('127.0.0.1', 0); $listener = new TCP\RestrictedListener($inner, [ IP\Address::parse('127.0.0.1'), new CIDR\Block('10.0.0.0/8'), ]); $address = $listener->getLocalAddress(); IO\write_line('Restricted listener on %s:%d', $address->host, $address->port ?? 0); $token = new Async\SignalCancellationToken(); Async\Scheduler::delay(Psl\DateTime\Duration::milliseconds(50), static fn() => ( // Simulate shutdown after 50ms $token->cancel() )); while (true) { try { $stream = $listener->accept($token); IO\write_line('Accepted from %s', $stream->getPeerAddress()->host); $stream->close(); } catch (Async\Exception\CancelledException) { break; } } $listener->close(); ``` ### Composite Listener `Network\CompositeListener` accepts connections from multiple listeners concurrently through a single `accept()` call. Each inner listener runs its own accept loop in a separate fiber, and connections are funneled through a shared channel. Closing the composite listener closes all inner listeners. ```php use Psl\Async; use Psl\IO; use Psl\Network; use Psl\TCP; // Listen on two different ports simultaneously $listener1 = TCP\listen('127.0.0.1', 0); $listener2 = TCP\listen('127.0.0.1', 0); $composite = new Network\CompositeListener([$listener1, $listener2]); $addr1 = $listener1->getLocalAddress(); $addr2 = $listener2->getLocalAddress(); IO\write_line('Listening on %s:%d and %s:%d', $addr1->host, $addr1->port ?? 0, $addr2->host, $addr2->port ?? 0); // Simulate shutdown after 50ms $token = new Async\SignalCancellationToken(); Async\Scheduler::delay(Psl\DateTime\Duration::milliseconds(50), static fn(string $_) => $token->cancel()); while (true) { try { $stream = $composite->accept($token); IO\write_line('Connection from %s', $stream->getPeerAddress()->host); $stream->close(); } catch (Async\Exception\CancelledException) { break; } } // Closes all inner listeners $composite->close(); ``` See [src/Psl/TCP/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/tcp/src/Psl/TCP/) for the full API. --- # TLS The `TLS` component provides a transport-agnostic API for TLS encryption. It operates on `Network\StreamInterface`, meaning it can upgrade any connected stream (TCP, Unix, or other) to a TLS-encrypted stream. It supports TLS 1.0--1.3, ALPN protocol negotiation, SNI-based certificate selection, mutual TLS authentication, session tickets, certificate pinning, and lazy handshake inspection. ## Usage ```php use Psl\TLS; // One-step TLS connection $tls = TLS\connect('example.com', 443); $tls->writeAll("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n"); $tls->shutdown(); $response = $tls->readAll(); $tls->close(); ``` ## Design ### Client Connections The simplest path is `TLS\connect()`, which opens a TCP connection and performs the handshake in one step. For more control, use `TLS\Connector` to upgrade an existing stream: ```php use Psl\TCP; use Psl\TLS; // Two-step: connect TCP first, then upgrade $stream = TCP\connect('example.com', 443); $tls = TLS\Connector::default()->connect($stream, 'example.com'); ``` Configure the client with `ClientConfiguration`: ```php use Psl\TLS; $config = TLS\ClientConfiguration::default() ->withAlpnProtocols(['h2', 'http/1.1']) ->withMinimumVersion(TLS\Version::Tls12); $tls = TLS\connect('example.com', 443, $config); ``` ### Server Connections Use `Acceptor` to perform TLS handshakes on incoming streams: ```php use Psl\TCP; use Psl\TLS; // Note: This example requires valid TLS certificate files to run. // Replace the paths below with actual certificate and key files. $certFile = '/etc/ssl/certs/server.pem'; $keyFile = '/etc/ssl/private/server.key'; $cert = TLS\Certificate::create($certFile, $keyFile); $acceptor = new TLS\Acceptor(TLS\ServerConfiguration::create($cert)->withAlpnProtocols(['h2', 'http/1.1'])); $listener = TCP\listen('0.0.0.0', 8443); while (true) { $stream = $listener->accept(); $tls = $acceptor->accept($stream); // ... handle encrypted connection $tls->close(); } ``` ### TLS Listener `TLS\Listener` wraps any `Network\ListenerInterface` and automatically performs TLS handshakes on accepted connections. This is the simplest way to build a TLS server: ```php use Psl\Async; use Psl\IO; use Psl\TCP; use Psl\TLS; // Create a TLS listener wrapping a TCP listener $certificate = new TLS\Certificate('server.pem', 'server.key'); $listener = new TLS\Listener(TCP\listen('127.0.0.1', 0), TLS\ServerConfiguration::create($certificate)); $address = $listener->getLocalAddress(); IO\write_line('TLS server listening on %s:%d', $address->host, $address->port ?? 0); // Simulate shutdown after 50ms $token = new Async\SignalCancellationToken(); Async\Scheduler::delay(Psl\DateTime\Duration::milliseconds(50), static fn(string $_) => $token->cancel()); while (true) { try { $stream = $listener->accept($token); IO\write_line('Accepted TLS connection from %s', $stream->getPeerAddress()->host); $stream->close(); } catch (Async\Exception\CancelledException) { break; } } $listener->close(); ``` ### SNI-Based Virtual Hosting (LazyAcceptor) `LazyAcceptor` peeks at the TLS ClientHello before completing the handshake. This lets you inspect the client's SNI hostname and choose the appropriate `ServerConfiguration` dynamically: ```php use Psl\TCP; use Psl\TLS; // Note: This example requires valid TLS certificate files to run. // Replace the paths below with actual certificate and key files. $lazy = TLS\LazyAcceptor::default(); $listener = TCP\listen('0.0.0.0', 8443); $configs = [ 'api.example.com' => TLS\ServerConfiguration::create(TLS\Certificate::create( '/etc/ssl/certs/api.pem', '/etc/ssl/private/api.key', )), 'www.example.com' => TLS\ServerConfiguration::create(TLS\Certificate::create( '/etc/ssl/certs/www.pem', '/etc/ssl/private/www.key', )), ]; $default = TLS\ServerConfiguration::create(TLS\Certificate::create( '/etc/ssl/certs/default.pem', '/etc/ssl/private/default.key', )); while (true) { $stream = $listener->accept(); $hello = $lazy->accept($stream); $server = $hello->getServerName(); $config = $server === null ? $default : $configs[$server] ?? $default; $tls = $hello->complete($config); // ... handle connection } ``` ### TLS Connection Pooling `TLS\TCPConnector` implements `TCP\ConnectorInterface`, which means it can be used with `TCP\SocketPool` to enable connection pooling for TLS connections. This avoids repeated TLS handshakes when making multiple requests to the same host: ```php use Psl\TCP; use Psl\TLS; // Create a TLS-aware connector that implements TCP\ConnectorInterface $connector = new TLS\TCPConnector( new TCP\Connector(), new TLS\Connector(TLS\ClientConfiguration::default()->withPeerVerification(true)), ); // Use it with a standard TCP socket pool for connection reuse $pool = new TCP\SocketPool($connector); // First request - establishes a new TLS connection $stream = $pool->checkout('example.com', 443); $stream->writeAll("GET / HTTP/1.1\r\nHost: example.com\r\nConnection: keep-alive\r\n\r\n"); $response = $stream->read(); $pool->checkin($stream); // Second request - reuses the existing TLS connection (no new handshake) $stream = $pool->checkout('example.com', 443); $stream->writeAll("GET /about HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"); $response = $stream->read(); $pool->clear($stream); $pool->close(); ``` ## Examples ### HTTPS Client with ALPN ```php use Psl\TLS; $config = TLS\ClientConfiguration::default()->withAlpnProtocols(['h2', 'http/1.1']); $tls = TLS\connect('example.com', 443, $config); $protocol = $tls->getState()->alpnProtocol; // 'h2' ``` ### Certificate Pinning ```php use Psl\TLS; // Note: Replace the fingerprints below with actual SHA-256 certificate fingerprints. $config = TLS\ClientConfiguration::default()->withPeerFingerprints([ 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2', 'e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6', ]); $tls = TLS\connect('api.example.com', 443, $config); ``` ### STARTTLS (Upgrade Mid-Connection) ```php use Psl\IO; use Psl\TCP; use Psl\TLS; // Note: This example requires valid TLS certificate files to run. // Replace the paths below with actual certificate and key files. $listener = TCP\listen('0.0.0.0', 2525); $cert = TLS\Certificate::create('/etc/ssl/certs/mail.pem', '/etc/ssl/private/mail.key'); $acceptor = new TLS\Acceptor(TLS\ServerConfiguration::create($cert)); $stream = $listener->accept(); // Plaintext phase $reader = new IO\Reader($stream); $line = $reader->readLine(); // "EHLO client.example.com" $stream->writeAll("250-mail.example.com\r\n250 STARTTLS\r\n"); $line = $reader->readLine(); // "STARTTLS" $stream->writeAll("220 Ready to start TLS\r\n"); // Upgrade to TLS $tls = $acceptor->accept($stream); // ... continue with encrypted SMTP ``` ### Inspecting Connection State ```php use Psl\TLS; $tls = TLS\connect('example.com', 443); $state = $tls->getState(); echo "TLS {$state->version->name}\n"; // "TLS Tls13" echo "Cipher: {$state->cipherName}\n"; // "TLS_AES_256_GCM_SHA384" echo "Bits: {$state->cipherBits}\n"; // 256 echo "ALPN: {$state->alpnProtocol}\n"; // "h2" or null if ($state->peerCertificate !== null) { echo "Subject: {$state->peerCertificate->subject}\n"; echo "Issuer: {$state->peerCertificate->issuer}\n"; echo "Valid until: {$state->peerCertificate->validTo->toRfc3339()}\n"; } $tls->close(); ``` ### Cancellation All TLS operations that suspend (handshakes) accept a `CancellationTokenInterface`. This allows you to cancel slow or hanging handshakes: - `Acceptor::accept($stream, $cancellation)` -- cancel server-side handshake - `LazyAcceptor::accept($stream, $cancellation)` -- cancel ClientHello peek - `ClientHello::complete($config, $cancellation)` -- cancel deferred handshake - `Connector::connect($stream, $host, $cancellation)` -- cancel client-side handshake - `TLS\connect($host, $port, $config, $cancellation)` -- cancellation propagates through both TCP connect and TLS handshake ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\IO; use Psl\TLS; // Cancel if the TLS handshake takes more than 5 seconds $token = new Async\TimeoutCancellationToken(Duration::seconds(5)); try { $stream = TLS\connect('example.com', 443, cancellation: $token); IO\write_line('Connected!'); $stream->close(); } catch (Async\Exception\CancelledException) { IO\write_line('TLS handshake timed out'); } ``` See [src/Psl/TLS/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/tls/src/Psl/TLS/) for the full API. --- # UDP The `UDP` component provides a non-blocking API for sending and receiving datagrams over UDP. It uses two distinct types for type-safe socket usage: - **`Socket`** -- an unconnected socket for sending/receiving to arbitrary addresses. - **`ConnectedSocket`** -- a connected socket for communicating with a single peer. ## Usage ```php use Psl\Async; use Psl\Network\Address; use Psl\UDP; $server = UDP\Socket::bind('127.0.0.1', 0); $serverAddress = $server->getLocalAddress(); Async\concurrently([ 'server' => static function () use ($server): void { [$data, $sender] = $server->receiveFrom(1024); $server->sendTo("echo: {$data}", $sender); $server->close(); }, 'client' => static function () use ($serverAddress): void { $client = UDP\Socket::bind('127.0.0.1', 0); $client->sendTo('hello', Address::udp($serverAddress->host, $serverAddress->port ?? 0)); [$response, $_] = $client->receiveFrom(1024); $client->close(); }, ]); ``` ## Design ### Unconnected vs. Connected Sockets An unconnected `Socket` can send to and receive from any address using `sendTo()` and `receiveFrom()`. When you call `connect()` on it, the original socket is closed and a `ConnectedSocket` is returned. The connected socket uses simpler `send()` and `receive()` methods since the peer is fixed. ```php use Psl\Async; use Psl\UDP; $server = UDP\Socket::bind('127.0.0.1', 0); $serverAddress = $server->getLocalAddress(); Async\concurrently([ 'server' => static function () use ($server): void { [$data, $sender] = $server->receiveFrom(1024); $server->sendTo("echo: {$data}", $sender); $server->close(); }, 'client' => static function () use ($serverAddress): void { // Start with an unconnected socket $socket = UDP\Socket::bind('127.0.0.1', 0); // Connect returns a ConnectedSocket -- the original socket is closed $connected = $socket->connect($serverAddress->host, $serverAddress->port ?? 0); // $socket is now closed -- only $connected is usable $connected->send('hello'); $_ = $connected->receive(512); $connected->close(); }, ]); ``` You can also create a connected socket directly: ```php use Psl\Async; use Psl\UDP; $server = UDP\Socket::bind('127.0.0.1', 0); $serverAddress = $server->getLocalAddress(); Async\concurrently([ 'server' => static function () use ($server): void { [$data, $sender] = $server->receiveFrom(1024); $server->sendTo("echo: {$data}", $sender); $server->close(); }, 'client' => static function () use ($serverAddress): void { // Create a connected socket directly $socket = UDP\connect($serverAddress->host, $serverAddress->port ?? 0); $socket->send('hello'); $_ = $socket->receive(512); $socket->close(); }, ]); ``` ### Configuration `UDP\Socket::bind()` accepts a `BindConfiguration` to control address reuse, port reuse, and broadcast. Configuration objects are immutable with `with*` builder methods. ## Examples ### Echo Server ```php use Psl\Async; use Psl\Network\Address; use Psl\UDP; $socket = UDP\Socket::bind('127.0.0.1', 0); $serverAddress = $socket->getLocalAddress(); echo "Listening on {$serverAddress->toString()}\n"; Async\concurrently([ 'server' => static function () use ($socket): void { // Echo one datagram then shut down [$data, $sender] = $socket->receiveFrom(65_507); $socket->sendTo($data, $sender); $socket->close(); }, 'client' => static function () use ($serverAddress): void { $client = UDP\Socket::bind('127.0.0.1'); $client->sendTo('ping', Address::udp($serverAddress->host, $serverAddress->port ?? 0)); [$response, $_] = $client->receiveFrom(1024); echo "Got: {$response}\n"; $client->close(); }, ]); ``` ### Peek ```php use Psl\Async; use Psl\Network\Address; use Psl\UDP; $socket = UDP\Socket::bind('127.0.0.1'); $serverAddress = $socket->getLocalAddress(); Async\concurrently([ 'server' => static function () use ($socket): void { // Peek at data without consuming it [$data, $_sender] = $socket->peekFrom(1024); echo "Peeked: {$data}\n"; // Same data is still available for receiveFrom() [$data, $_sender] = $socket->receiveFrom(1024); echo "Received: {$data}\n"; $socket->close(); }, 'client' => static function () use ($serverAddress): void { $client = UDP\Socket::bind('127.0.0.1'); $client->sendTo('hello', Address::udp($serverAddress->host, $serverAddress->port ?? 0)); $client->close(); }, ]); ``` See [src/Psl/UDP/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/udp/src/Psl/UDP/) for the full API. --- # Unix The `Unix` component provides a non-blocking API for Unix domain socket connections, built on top of PSL's IO and Network abstractions. Unix domain sockets provide local inter-process communication without the overhead of TCP's network stack. Not available on Windows. ## Usage ```php use Psl\Async; use Psl\Env; use Psl\Filesystem; use Psl\Unix; $path = Filesystem\create_temporary_file(Env\temp_dir(), 'psl-unix-echo-sock-'); $listener = Unix\listen($path); Async\concurrently([ 'server' => static function () use ($listener): void { $connection = $listener->accept(); $data = $connection->readAll(); $connection->writeAll("echo: {$data}"); $connection->close(); $listener->close(); }, 'client' => static function () use ($path): void { $client = Unix\connect($path); $client->writeAll('hello'); $client->shutdown(); $_ = $client->readAll(); $client->close(); }, ]); @unlink($path); ``` ## Configuration `Unix\listen()` accepts a `ListenConfiguration` to control backlog (default 512) and idle connections (default 256). Configuration objects are immutable with `with*` builder methods. ## Examples ### Echo Server ```php use Psl\Async; use Psl\Env; use Psl\Filesystem; use Psl\Unix; $path = Filesystem\create_temporary_file(Env\temp_dir(), 'psl-unix-server-'); $listener = Unix\listen($path); Async\concurrently([ 'server' => static function () use ($listener, $path): void { echo "Listening on {$path}\n"; // Accept one connection then shut down $connection = $listener->accept(); Async\run(static function () use ($connection): void { $data = $connection->readAll(); $connection->writeAll($data); $connection->close(); })->await(); $listener->close(); }, 'client' => static function () use ($path): void { $client = Unix\connect($path); $client->writeAll('hello from client'); $client->shutdown(); $response = $client->readAll(); echo "Got: {$response}\n"; $client->close(); }, ]); @unlink($path); ``` ### Client with Cancellation ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\Env; use Psl\Filesystem; use Psl\Unix; $path = Filesystem\create_temporary_file(Env\temp_dir(), 'psl-unix-timeout-sock-'); $listener = Unix\listen($path); Async\concurrently([ 'server' => static function () use ($listener): void { $connection = $listener->accept(); $data = $connection->readAll(); $connection->writeAll("reply: {$data}"); $connection->close(); $listener->close(); }, 'client' => static function () use ($path): void { $client = Unix\connect($path, new Async\TimeoutCancellationToken(Duration::seconds(5))); $client->writeAll('ping'); $client->shutdown(); $response = $client->readAll(); $client->close(); }, ]); @unlink($path); ``` ### Concurrent Connections ```php use Psl\Async; use Psl\Env; use Psl\Filesystem; use Psl\Unix; $path = Filesystem\create_temporary_file(Env\temp_dir(), 'psl-unix-concurrent-'); $listener = Unix\listen($path); Async\concurrently([ 'server' => static function () use ($listener): void { // Accept multiple clients concurrently for ($i = 0; $i < 3; $i++) { $connection = $listener->accept(); Async\run(static function () use ($connection): void { $request = $connection->readAll(); $connection->writeAll("handled: {$request}"); $connection->close(); })->ignore(); } $listener->close(); }, 'clients' => static function () use ($path): void { // Spawn 3 clients concurrently Async\concurrently([ static function () use ($path): void { $client = Unix\connect($path); $client->writeAll('request-1'); $client->shutdown(); $client->readAll(); $client->close(); }, static function () use ($path): void { $client = Unix\connect($path); $client->writeAll('request-2'); $client->shutdown(); $client->readAll(); $client->close(); }, static function () use ($path): void { $client = Unix\connect($path); $client->writeAll('request-3'); $client->shutdown(); $client->readAll(); $client->close(); }, ]); }, ]); @unlink($path); ``` See [src/Psl/Unix/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/unix/src/Psl/Unix/) for the full API. --- # DNS The `DNS` component provides async DNS resolution with connection pooling, EDNS0, DNS-over-TLS, DNS-over-HTTPS, system configuration detection, hosts file support, and search domain expansion. All resolvers are non-blocking and composable via the decorator pattern. ## Basic Usage `SystemResolver` mirrors your operating system's DNS behavior. It can be used as a default parameter value since it requires no arguments. ```php use Psl\DNS; use Psl\DNS\Record\ARecord; use Psl\DNS\Record\RecordType; // System resolver mirrors your OS DNS settings $resolver = new DNS\SystemResolver(); // Query A records $response = $resolver->query('example.com', RecordType::A); // Check the result if ($response->code->isSuccess()) { foreach ($response->getAnswerRecords(ARecord::class) as $record) { $record->address; // Psl\IP\Address } } ``` By default, `SystemResolver` uses plain UDP with TCP fallback for each nameserver discovered from the OS. You can encrypt all system DNS traffic with DNS-over-TLS by disabling UDP and passing a TLS-enabled connector. With `udp: false`, all queries go directly over TCP — no plaintext UDP is sent. Combined with a TLS connector, this turns all system nameserver communication into DNS-over-TLS (DoT, RFC 7858) while still using the OS-configured nameserver addresses. ```php use Psl\DNS; use Psl\TCP; use Psl\TLS; // Encrypt all system DNS traffic with DNS-over-TLS. // udp: false ensures no plaintext UDP queries are sent. // The TLS connector wraps every TCP connection in TLS (DoT, RFC 7858). // Nameserver addresses are still read from the OS configuration. $resolver = new DNS\SystemResolver( udp: false, connector: new TLS\TCPConnector(new TCP\Connector(), new TLS\Connector(new TLS\ClientConfiguration())), ); ``` ## Resolver Types Resolvers can be composed for different strategies: direct UDP/TCP, fallback on truncation, racing multiple nameservers, DNS-over-TLS, or DNS-over-HTTPS. ```php use Psl\DNS; use Psl\TCP; use Psl\TLS; // UDP resolver (standard DNS, port 53) $udp = new DNS\UDPResolver('8.8.8.8'); // TCP resolver with connection pooling $tcp = new DNS\TCPResolver('8.8.8.8'); // DNS-over-TLS (port 853) $dot = new DNS\TCPResolver( '8.8.8.8', port: 853, connector: new TLS\TCPConnector(new TCP\Connector(), new TLS\Connector(new TLS\ClientConfiguration())), ); // DNS-over-HTTPS (RFC 8484) $doh = new DNS\HTTPSResolver('https://1.1.1.1/dns-query'); // UDP with automatic TCP fallback on truncated responses $fallback = new DNS\FallbackResolver([$udp, $tcp]); // Race multiple nameservers, return the fastest response $racing = new DNS\RacingResolver([ new DNS\UDPResolver('8.8.8.8'), new DNS\UDPResolver('1.1.1.1'), ]); ``` ## Record Types Over 20 DNS record types are supported. Responses provide typed access via `getAnswerRecords()` and `getFirstAnswerRecord()`. ```php use Psl\DNS; use Psl\DNS\Record; use Psl\IP\Address; $resolver = new DNS\SystemResolver(); // Query different record types $a = $resolver->query('example.com', Record\RecordType::A); $aaaa = $resolver->query('example.com', Record\RecordType::AAAA); $mx = $resolver->query('example.com', Record\RecordType::MX); $txt = $resolver->query('example.com', Record\RecordType::TXT); $srv = $resolver->query('_http._tcp.example.com', Record\RecordType::SRV); // Extract typed records from a response foreach ($a->getAnswerRecords(Record\ARecord::class) as $record) { $record->name; // "example.com" $record->duration; // TTL as Duration $record->address; // Psl\IP\Address } foreach ($mx->getAnswerRecords(Record\MXRecord::class) as $record) { $record->preference; // int $record->exchange; // "mail.example.com" } // Reverse lookup $ptr = $resolver->reverseQuery(Address::parse('8.8.8.8')); foreach ($ptr->getAnswerRecords(Record\PTRRecord::class) as $record) { $record->target; // "dns.google" } ``` ## Caching `CachedResolver` wraps any resolver with TTL-aware caching. Cache TTL is derived from the minimum record TTL in each response. ```php use Psl\Cache; use Psl\DNS; // Wrap any resolver with caching $resolver = new DNS\CachedResolver(new DNS\SystemResolver(), new Cache\LocalStore()); // First query hits the network $response = $resolver->query('example.com', DNS\Record\RecordType::A); // Second query returns from cache (TTL-aware) $cached = $resolver->query('example.com', DNS\Record\RecordType::A); ``` ## Split-Horizon DNS `SplitHorizonResolver` routes queries to different resolvers based on the domain name. Domain matching is suffix-based on domain boundaries. ```php use Psl\DNS; // Route queries for internal domains to a private nameserver, // everything else to a public resolver $resolver = new DNS\SplitHorizonResolver(routes: [ new DNS\Route(domains: ['corp.internal', 'dev.internal'], resolver: new DNS\UDPResolver('10.0.0.53')), ], default: new DNS\SystemResolver()); // Uses the private nameserver (matches corp.internal) $resolver->query('db.corp.internal', DNS\Record\RecordType::A); // Uses the system resolver (no matching route) $resolver->query('example.com', DNS\Record\RecordType::A); ``` ## Search Domains `SearchDomainResolver` expands short names by appending search domains. Names with sufficient dots (configurable via `$numberOfDots`) are queried as-is. ```php use Psl\DNS; use Psl\DNS\Record\RecordType; // Expand short names by appending search domains $resolver = new DNS\SearchDomainResolver( inner: new DNS\SystemResolver(), searchDomains: ['example.com', 'corp.example.com'], ); // Queries "db.example.com", then "db.corp.example.com" if NXDOMAIN $resolver->query('db', RecordType::A); // Fully qualified names skip expansion $resolver->query('google.com', RecordType::A); ``` ## System Configuration Load your OS DNS settings and hosts file directly. Both use async process execution for non-blocking reads. ```php use Psl\DNS\System\HostsFile\HostsFile; use Psl\DNS\System\Settings; // Load system DNS settings (nameservers, search domains) $settings = Settings::load(); foreach ($settings->nameservers as $ns) { $ns->host; // "8.8.8.8" $ns->port; // 53 $ns->forDomains; // [] for global, ["corp.internal"] for scoped } $settings->searchDomains; // ["example.com", "corp.example.com"] // Load and query the hosts file $hosts = HostsFile::load(); $addresses = $hosts->lookup('localhost'); // [Address("127.0.0.1"), Address("::1")] ``` ## EDNS0 Options Pass EDNS0 options to any query for features like DNS cookies, client subnet hints, padding, and NSID. ```php use Psl\DNS; use Psl\DNS\EDNS; use Psl\DNS\Record\RecordType; use Psl\IP; use Psl\SecureRandom; $resolver = new DNS\SystemResolver(); // Query with EDNS0 options $response = $resolver->query('example.com', RecordType::A, ednsOptions: [ new EDNS\CookieOption(clientCookie: SecureRandom\bytes(8)), new EDNS\ECSOption(IP\Address::parse('203.0.113.0'), sourcePrefixLength: 24, scopePrefixLength: 0), new EDNS\PaddingOption(128), new EDNS\NSIDOption(), ]); ``` ## DNS-over-HTTPS `HTTPSResolver` sends queries over HTTPS (RFC 8484) using the HTTP client. HTTP/2 multiplexing allows concurrent queries to share a single connection. Pass your own `ClientInterface` to share connection pools across resolvers. ```php use Psl\Cache; use Psl\DNS; use Psl\HTTP\Client; // Default client — creates one internally $resolver = new DNS\HTTPSResolver('https://1.1.1.1/dns-query'); // Bring your own client for shared connection pooling $client = new Client\Client(); $cloudflare = new DNS\HTTPSResolver('https://1.1.1.1/dns-query', $client); $google = new DNS\HTTPSResolver('https://dns.google/dns-query', $client); // Race two DoH providers $racing = new DNS\RacingResolver([$cloudflare, $google]); // Compose with caching $cache = new Cache\LocalStore(); $cached = new DNS\CachedResolver($racing, $cache); $response = $cached->query('example.com', DNS\Record\RecordType::A); ``` ## Blocking System Resolver `BlockingSystemResolver` wraps PHP's built-in `dns_get_record()` for synchronous DNS resolution with zero overhead. No event loop, no fibers, no connection pooling. Useful as a lightweight fallback behind a `StaticResolver`, or in environments where async DNS is unnecessary. Supports A, AAAA, CNAME, MX, NS, PTR, SOA, SRV, TXT, CAA, and NAPTR record types. EDNS0 options are ignored. Queries for unsupported types return NXDOMAIN. ```php use Psl\DNS; use Psl\DNS\Record\RecordType; // BlockingSystemResolver wraps PHP's dns_get_record() for synchronous, // zero-overhead DNS resolution. No event loop, no fibers, no connection pooling. $resolver = new DNS\BlockingSystemResolver(); // Query A records $response = $resolver->query('example.com', RecordType::A); foreach ($response->answers as $record) { /** @var DNS\Record\ARecord $record */ $record->address->toString(); $record->duration->getSeconds(); } // Query MX records $response = $resolver->query('example.com', RecordType::MX); foreach ($response->answers as $record) { /** @var DNS\Record\MXRecord $record */ $record->exchange; $record->preference; } // Non-existent domain returns NXDOMAIN, never throws $response = $resolver->query('nonexistent.invalid', RecordType::A); $response->code; // ResponseCode::NonExistentDomain // Reverse lookup via PTR $response = $resolver->reverseQuery(Psl\IP\Address::parse('8.8.8.8')); foreach ($response->answers as $record) { /** @var DNS\Record\PTRRecord $record */ $record->target; // "dns.google" } ``` ## HTTP Client Integration `DNS\HTTP\Connector` is a connector decorator that resolves hostnames via any DNS resolver before delegating to an inner HTTP client connector. This enables fully async DNS resolution, DNS-over-TLS, DNS-over-HTTPS, DNSSEC validation, or custom resolution strategies for all HTTP requests. When the origin host is already an IP address, no DNS query is performed. For HTTPS connections, the original hostname is preserved for TLS SNI so certificate validation works correctly even though the TCP connection targets the resolved IP. ```php use Psl\Cache; use Psl\DNS; use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\URL; // Build a resolver chain: cached async UDP resolution with TCP fallback on truncation. $resolver = new DNS\CachedResolver(new DNS\FallbackResolver([ new DNS\UDPResolver(host: '1.1.1.1'), new DNS\TCPResolver(host: '1.1.1.1'), ]), new Cache\LocalStore()); // Wrap the pooled connector with DNS resolution. // All HTTP requests will resolve hostnames through the resolver above, // benefiting from caching, async I/O, and custom nameserver selection. $connector = new DNS\HTTP\Connector(new Client\Connection\PooledConnector(), $resolver); $client = new Client\Client(connector: $connector); $tx = $client->send(new Message\Request(method: 'GET', url: URL\parse('https://example.com'))); $tx->response->status; $tx->response->body?->readAll(); ``` ## Kitchen Sink Compose static entries, hosts file, search domains, split-horizon routing, multiple racing nameservers with DNS-over-TLS or DNS-over-HTTPS, TCP fallback, and caching into a single resolver that uses every feature. ```php use Psl\Cache; use Psl\DateTime\Duration; use Psl\DNS; use Psl\DNS\Record\ARecord; use Psl\DNS\Record\RecordType; use Psl\DNS\System\HostsFile\HostsFile; use Psl\IO; use Psl\IP\Address; use Psl\TCP; use Psl\TLS; $cache = new Cache\LocalStore(); // 1. Static entries for internal services (never hit the network) $static = new DNS\StaticResolver([ 'db.internal' => [ RecordType::A->value => [ new ARecord('db.internal', Duration::hours(999), Address::parse('10.0.0.5')), ], ], 'cache.internal' => [ RecordType::A->value => [ new ARecord('cache.internal', Duration::hours(999), Address::parse('10.0.0.6')), ], ], ]); // 2. Internal nameserver for corporate domains (split-horizon) $internalDns = new DNS\FallbackResolver([ new DNS\UDPResolver('10.0.0.53'), new DNS\TCPResolver('10.0.0.53'), ]); // 3. Race multiple public nameservers for fastest response $public = new DNS\RacingResolver([ // Cloudflare DNS-over-TLS new DNS\FallbackResolver([ new DNS\UDPResolver('1.1.1.1', dnssec: true), new DNS\TCPResolver( '1.1.1.1', port: 853, dnssec: true, connector: new TLS\TCPConnector(new TCP\Connector(), new TLS\Connector(new TLS\ClientConfiguration())), ), ]), // Google DNS-over-TLS new DNS\FallbackResolver([ new DNS\UDPResolver('8.8.8.8', dnssec: true), new DNS\TCPResolver( '8.8.8.8', port: 853, dnssec: true, connector: new TLS\TCPConnector(new TCP\Connector(), new TLS\Connector(new TLS\ClientConfiguration())), ), ]), // Cloudflare secondary (plain TCP fallback) new DNS\FallbackResolver([ new DNS\UDPResolver('1.0.0.1', dnssec: true), new DNS\TCPResolver('1.0.0.1', dnssec: true), ]), // Google secondary (plain TCP fallback) new DNS\FallbackResolver([ new DNS\UDPResolver('8.8.4.4', dnssec: true), new DNS\TCPResolver('8.8.4.4', dnssec: true), ]), ]); // 4. Static overrides first, then public nameservers $base = new DNS\FallbackResolver([$static, $public]); // 5. Route corporate domains to internal DNS, everything else to public $routed = new DNS\SplitHorizonResolver(routes: [ new DNS\Route(['corp.internal', 'dev.internal'], $internalDns), ], default: $base); // 6. Expand short names using search domains $searched = new DNS\SearchDomainResolver($routed, ['corp.internal', 'dev.internal']); // 7. Check the hosts file before any network query $hostsAware = new DNS\HostsFileResolver($searched, HostsFile::load()); // 8. Cache everything on top $resolver = new DNS\CachedResolver($hostsAware, $cache); // 9. Profit! $response = $resolver->query('php-standard-library.dev', RecordType::A); IO\write_line( 'php-standard-library.dev -> %s', $response->getFirstAnswerRecord(ARecord::class)?->address->toString() ?? '', ); ``` ## Supported Record Types | Type | RFC | Description | |------|-----|-------------| | A | RFC 1035 | IPv4 address | | AAAA | RFC 3596 | IPv6 address | | NS | RFC 1035 | Authoritative nameserver | | CNAME | RFC 1035 | Canonical name alias | | MX | RFC 1035 | Mail exchange | | TXT | RFC 1035 | Text records (SPF, DKIM, etc.) | | SRV | RFC 2782 | Service location | | SOA | RFC 1035 | Start of authority | | PTR | RFC 1035 | Reverse lookup pointer | | CAA | RFC 8659 | Certificate authority authorization | | SSHFP | RFC 4255 | SSH fingerprint | | TLSA | RFC 6698 | TLS certificate association (DANE) | | SVCB | RFC 9460 | Service binding | | HTTPS | RFC 9460 | HTTPS service binding | | LOC | RFC 1876 | Geographic location | | NAPTR | RFC 3403 | Naming authority pointer | | DS | RFC 4034 | DNSSEC delegation signer | | DNSKEY | RFC 4034 | DNSSEC public key | | RRSIG | RFC 4034 | DNSSEC signature | | NSEC | RFC 4034 | DNSSEC authenticated denial | | NSEC3 | RFC 5155 | DNSSEC hashed denial | ## System Configuration Sources | OS | Source | Method | |----|--------|--------| | Linux/FreeBSD | `/etc/resolv.conf` | `cat` via Process | | macOS | `scutil --dns` | Process (includes split DNS, custom ports) | | Windows | `ipconfig /all` | Process | | All platforms | hosts file | Process | ## RFC Compliance | RFC | Coverage | |-----|----------| | RFC 1035 | DNS wire format, A, NS, CNAME, MX, TXT, SOA, PTR records | | RFC 2782 | SRV records | | RFC 3403 | NAPTR records | | RFC 3596 | AAAA records | | RFC 4034 | DNSSEC record types (DS, DNSKEY, RRSIG, NSEC) | | RFC 4255 | SSHFP records | | RFC 5155 | NSEC3 and NSEC3PARAM records | | RFC 6698 | TLSA records (DANE) | | RFC 6891 | EDNS0 (OPT record, extended RCODE, UDP payload size) | | RFC 7858 | DNS-over-TLS via TLS client configuration | | RFC 8484 | DNS-over-HTTPS via HTTP client | | RFC 7871 | EDNS Client Subnet option | | RFC 7873 | DNS Cookies option | | RFC 7830 | EDNS TCP Keepalive option | | RFC 8467 | EDNS Padding option | | RFC 8659 | CAA records | | RFC 9460 | SVCB and HTTPS records | See [src/Psl/DNS/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/dns/src/Psl/DNS/) for the full API. --- # DNSSEC The `DNSSEC` component adds full DNSSEC validation to DNS resolution. It is a standalone package (`php-standard-library/dnssec`) that requires a `Psl\DNS\ResolverInterface` instance from the `psl/dns` package. DNSSEC validation verifies that DNS responses have not been tampered with by checking cryptographic signatures (RRSIG), walking trust chains from the DNS root (DS/DNSKEY), and validating authenticated denial of existence proofs (NSEC/NSEC3). ## Getting Started To use DNSSEC, you need two things: a DNS resolver that requests DNSSEC records (`dnssec: true`), and the DNSSEC validation layer that wraps it. ```php use Psl\DNS; use Psl\DNS\Record\RecordType; use Psl\DNSSEC; // Create a DNSSEC-validating resolver $inner = new DNS\SystemResolver(dnssec: true); $trustChain = new DNSSEC\TrustChainResolver($inner); $resolver = new DNSSEC\SecureResolver($inner, $trustChain); // Queries are validated: signatures, trust chain, NSEC proofs $response = $resolver->query('example.com', RecordType::A); // Throws SignatureFailedException, BrokenTrustChainException, // InvalidProofException, or UnsignedResponseException on failure ``` ## RRSIG: Signature Verification Every DNSSEC-signed response includes RRSIG records alongside the answer records. Each RRSIG contains a cryptographic signature over a set of records, the signer's identity, and the key tag to locate the corresponding DNSKEY. When `SecureResolver` receives a response, it verifies that the RRSIG signatures are valid using the zone's public keys (DNSKEY records). If any signature is invalid or expired, `SignatureFailedException` is thrown. ## DS/DNSKEY: Trust Chain DNSSEC trust is established through a chain from the DNS root to the target zone. Each parent zone contains a DS (Delegation Signer) record that is a cryptographic hash of the child zone's DNSKEY. By verifying each link in the chain, `TrustChainResolver` proves that the zone's keys are authorized by the parent, all the way up to the root trust anchor. The walk for `example.com` goes: root (.) DNSKEY > `com.` DS > `com.` DNSKEY > `example.com.` DS > `example.com.` DNSKEY. ## NSEC/NSEC3: Authenticated Denial When a domain does not exist (NXDOMAIN), the server must prove it. NSEC records list the names that DO exist in sorted order, proving there is no name between them. NSEC3 records do the same but with hashed names to prevent zone enumeration. `SecureResolver` validates these proofs automatically. If the proof is missing or invalid, `InvalidProofException` is thrown. ## Trust Anchors `TrustChainResolver` uses the IANA root KSK as its trust anchor by default. For fully offline environments, `StaticTrustChainResolver` accepts pre-loaded DNSKEY records per zone, bypassing all network queries for trust chain resolution. ```php use Psl\DateTime\Duration; use Psl\DNS; use Psl\DNS\DNSSEC\Algorithm; use Psl\DNS\Record\DNSKEYRecord; use Psl\DNSSEC; $inner = new DNS\SystemResolver(dnssec: true); // Default: uses IANA root KSK, walks the DS/DNSKEY chain via DNS queries $trustChain = new DNSSEC\TrustChainResolver($inner); // Fully offline: pre-loaded DNSKEY records per zone, no network queries $rootDnskey = new DNSKEYRecord('.', Duration::seconds(0), 257, 3, Algorithm::RSASHA256, '...'); $comDnskey = new DNSKEYRecord('com.', Duration::seconds(0), 257, 3, Algorithm::ECDSAP256SHA256, '...'); $offline = new DNSSEC\StaticTrustChainResolver([ '.' => [$rootDnskey], 'com.' => [$comDnskey], ]); $resolver = new DNSSEC\SecureResolver($inner, $trustChain); ``` ## Validation Errors DNSSEC validation produces specific exceptions for each failure mode: | Exception | Meaning | |-----------|---------| | `SignatureFailedException` | RRSIG verification failed or crypto format is invalid | | `BrokenTrustChainException` | DS/DNSKEY trust chain is incomplete or invalid | | `InvalidProofException` | NSEC/NSEC3 proof does not check out | | `UnsignedResponseException` | Expected DNSSEC-signed data but response was unsigned | All exceptions extend `Psl\DNSSEC\Exception\RuntimeException`. ## Caching DNSSEC validation is expensive - each query triggers a trust chain walk from the root zone through every intermediate zone to the target. `CachedTrustChainResolver` caches the validated trust chain results so subsequent queries to the same TLD reuse the cached chain. Combine with `CachedResolver` on top to cache the final validated responses. ```php use Psl\Cache; use Psl\DNS; use Psl\DNS\Record\RecordType; use Psl\DNSSEC; $cache = new Cache\LocalStore(); // The inner resolver must request DNSSEC records $dnsResolver = new DNS\SystemResolver(dnssec: true); // Cache trust chain results to avoid repeated root-to-leaf walks // Without caching, every query triggers DS/DNSKEY lookups for each zone $trustChain = new DNSSEC\CachedTrustChainResolver(new DNSSEC\TrustChainResolver($dnsResolver), $cache); // SecureResolver validates every response using the cached trust chain $secureResolver = new DNSSEC\SecureResolver($dnsResolver, $trustChain); // Cache the validated DNS responses on top $resolver = new DNS\CachedResolver($secureResolver, $cache); // First query: full trust chain walk (root -> com. -> example.com.) $resolver->query('example.com', RecordType::A); // Second query for same TLD: trust chain for "com." is cached $resolver->query('another.com', RecordType::A); // Third query for same domain: entire response is cached $resolver->query('example.com', RecordType::A); ``` ## Kitchen Sink Compose DNSSEC validation with static entries, hosts file, search domains, split-horizon routing, multiple racing DNS-over-TLS nameservers, and caching. DNSSEC validation only wraps the public DNS path; trusted sources (static, internal, hosts file) are outside the validation boundary. ```php use Psl\Cache; use Psl\DateTime\Duration; use Psl\DNS; use Psl\DNS\Record\ARecord; use Psl\DNS\Record\RecordType; use Psl\DNS\System\HostsFile\HostsFile; use Psl\DNSSEC; use Psl\IO; use Psl\IP\Address; use Psl\TCP; use Psl\TLS; $cache = new Cache\LocalStore(); // 1. Static entries (trusted by definition, no DNSSEC needed) $static = new DNS\StaticResolver([ 'db.internal' => [ RecordType::A->value => [ new ARecord('db.internal', Duration::hours(999), Address::parse('10.0.0.5')), ], ], 'cache.internal' => [ RecordType::A->value => [ new ARecord('cache.internal', Duration::hours(999), Address::parse('10.0.0.6')), ], ], ]); // 2. Internal nameserver for corporate domains (trusted network, no DNSSEC) $internalDns = new DNS\FallbackResolver([ new DNS\UDPResolver('10.0.0.53'), new DNS\TCPResolver('10.0.0.53'), ]); // 3. Race multiple DNSSEC-enabled public nameservers $public = new DNS\RacingResolver([ // Cloudflare DNS-over-TLS new DNS\FallbackResolver([ new DNS\UDPResolver('1.1.1.1', dnssec: true), new DNS\TCPResolver( '1.1.1.1', port: 853, dnssec: true, connector: new TLS\TCPConnector(new TCP\Connector(), new TLS\Connector(new TLS\ClientConfiguration())), ), ]), // Google DNS-over-TLS new DNS\FallbackResolver([ new DNS\UDPResolver('8.8.8.8', dnssec: true), new DNS\TCPResolver( '8.8.8.8', port: 853, dnssec: true, connector: new TLS\TCPConnector(new TCP\Connector(), new TLS\Connector(new TLS\ClientConfiguration())), ), ]), // Cloudflare secondary (plain TCP fallback) new DNS\FallbackResolver([ new DNS\UDPResolver('1.0.0.1', dnssec: true), new DNS\TCPResolver('1.0.0.1', dnssec: true), ]), // Google secondary (plain TCP fallback) new DNS\FallbackResolver([ new DNS\UDPResolver('8.8.4.4', dnssec: true), new DNS\TCPResolver('8.8.4.4', dnssec: true), ]), ]); // 4. DNSSEC: validate public DNS responses only $public = new DNSSEC\SecureResolver( $public, new DNSSEC\CachedTrustChainResolver(new DNSSEC\TrustChainResolver($public), $cache), ); // 5. Static overrides first, then DNSSEC-validated public DNS $base = new DNS\FallbackResolver([$static, $public]); // 6. Route corporate domains to internal DNS (unvalidated), // everything else to static + DNSSEC-validated public $routed = new DNS\SplitHorizonResolver(routes: [ new DNS\Route(['corp.internal', 'dev.internal'], $internalDns), ], default: $base); // 7. Expand short names using search domains $searched = new DNS\SearchDomainResolver($routed, ['corp.internal', 'dev.internal']); // 8. Check the hosts file before any network query $hostsAware = new DNS\HostsFileResolver($searched, HostsFile::load()); // 9. Cache everything on top $resolver = new DNS\CachedResolver($hostsAware, $cache); // 10. Profit! $response = $resolver->query('php-standard-library.dev', RecordType::A); IO\write_line( 'php-standard-library.dev -> %s', $response->getFirstAnswerRecord(ARecord::class)?->address->toString() ?? '', ); ``` ## Supported Algorithms | Algorithm | RFC | |-----------|-----| | RSA/SHA-1 | RFC 4034 | | RSA/SHA-256 | RFC 5702 | | RSA/SHA-512 | RFC 5702 | | ECDSA P-256/SHA-256 | RFC 6605 | | ECDSA P-384/SHA-384 | RFC 6605 | | Ed25519 | RFC 8080 | | Ed448 | RFC 8080 | ## RFC Compliance | RFC | Coverage | |-----|----------| | RFC 4033 | DNSSEC introduction and requirements | | RFC 4034 | DNSSEC resource records (RRSIG, DNSKEY, DS, NSEC) | | RFC 4035 | DNSSEC protocol modifications and validation | | RFC 5155 | NSEC3 hashed authenticated denial of existence | | RFC 5702 | RSA/SHA-256 and RSA/SHA-512 for DNSSEC | | RFC 6605 | ECDSA for DNSSEC (P-256/SHA-256, P-384/SHA-384) | | RFC 8080 | Edwards-curve algorithms for DNSSEC (Ed25519, Ed448) | See [src/Psl/DNSSEC/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/dnssec/src/Psl/DNSSEC/) for the full API. --- # H2 The `H2` component implements the HTTP/2 binary framing protocol (RFC 9113) on top of PSL's IO handles. It is a building block for higher-level components like HTTP clients and servers, not an HTTP implementation itself. The protocol is implemented in full - if the RFC defines it, H2 supports it. ## Architecture H2 connections take a `Psl\IO\ReadHandleInterface & Psl\IO\WriteHandleInterface` instance. That could be a TCP stream, a TLS connection, a Unix socket, or anything else that implements those interfaces. H2 does not care about the transport. PSL's IO and Async components are built on top of [Revolt](https://github.com/revoltphp/event-loop). H2 is concurrent by design: operations like `waitForSendWindow()` suspend the current fiber and let the event loop run until a WINDOW_UPDATE arrives. Multiple streams on the same connection run in parallel through fibers. To use H2 with other event loop libraries like ReactPHP, use [revoltphp/event-loop-adapter-react](https://github.com/revoltphp/event-loop-adapter-react) and wrap your stream in a class implementing `ReadHandleInterface & WriteHandleInterface`. ## Client and Server Connections are split into `ClientConnection` and `ServerConnection`, each implementing their role-specific interface. Both share common operations (sending headers, data, ping, goaway) via `ConnectionInterface`. ```php use Psl\H2; use Psl\H2\Event; use Psl\HPACK\Header; use Psl\Network; // Create a socket pair for client-server communication [$clientSocket, $serverSocket] = Network\socket_pair(); $client = new H2\ClientConnection($clientSocket); $server = new H2\ServerConnection($serverSocket); // Initialize both sides (sends connection preface + SETTINGS) $client->initialize(); $server->initialize(); // Complete the handshake $server->readClientPreface(); $server->readEvent(); // client SETTINGS $client->readEvent(); // server SETTINGS $client->readEvent(); // SETTINGS ACK $server->readEvent(); // SETTINGS ACK // Client sends a request $streamId = $client->nextStreamId(); $client->sendHeaders( $streamId, [ new Header(':method', 'GET'), new Header(':path', '/hello'), new Header(':scheme', 'https'), new Header(':authority', 'example.com'), ], endStream: true, ); // Server reads the request $events = $server->readEvent(); foreach ($events as $event) { if (!$event instanceof Event\HeadersReceived) { continue; } // Server sends a response $server->sendHeadersWithStatus($event->streamId, '200', [ new Header('content-type', 'text/plain'), ]); $server->sendData($event->streamId, 'Hello, World!', endStream: true); } ``` ## Flow Control `sendData()` sends a single frame and requires the caller to manage flow control windows. For large payloads, use `sendAllData()` which automatically splits data into window-sized chunks and waits for WINDOW_UPDATE frames. ```php use Psl\H2; use Psl\HPACK\Header; use Psl\Str; /** @var H2\ClientConnectionInterface $client */ /** @var H2\ServerConnectionInterface $server */ $streamId = $client->nextStreamId(); $client->sendHeaders($streamId, [ new Header(':method', 'POST'), new Header(':path', '/upload'), new Header(':scheme', 'https'), new Header(':authority', 'example.com'), ]); // sendAllData handles flow control - no manual window management needed $largePayload = Str\repeat('data', 10_000); $client->sendAllData($streamId, $largePayload, endStream: true); ``` ## Server Push Server push is part of the HTTP/2 specification, so H2 implements it. Whether higher-level code chooses to use it is an application-level decision. `sendPushPromise()` sends pushes, `rejectPush()` declines them. ```php use Psl\H2; use Psl\HPACK\Header; /** @var H2\ServerConnectionInterface $server */ /** @var H2\ClientConnectionInterface $client */ // Server pushes a CSS file alongside the HTML response $requestStreamId = 1; // client-initiated stream $pushStreamId = $server->nextStreamId(); $server->sendPushPromise($requestStreamId, $pushStreamId, [ new Header(':method', 'GET'), new Header(':path', '/style.css'), new Header(':scheme', 'https'), new Header(':authority', 'example.com'), ]); // Send the pushed response $server->sendHeadersWithStatus($pushStreamId, '200', [ new Header('content-type', 'text/css'), ]); $server->sendData($pushStreamId, 'body { color: red; }', endStream: true); // Client can reject unwanted pushes $client->rejectPush($pushStreamId); ``` ## Extended CONNECT RFC 8441 enables protocols like WebSocket to run over HTTP/2 streams. The client sends an extended CONNECT request with a `:protocol` pseudo-header, and after the server responds with 200, the stream becomes a bidirectional byte tunnel. ```php use Psl\H2; use Psl\HPACK\Header; /** @var H2\ClientConnectionInterface $client */ /** @var H2\ServerConnectionInterface $server */ $streamId = $client->nextStreamId(); $client->sendExtendedConnect( $streamId, protocol: 'websocket', scheme: 'https', authority: 'example.com', path: '/chat', extraHeaders: [ new Header('origin', 'https://example.com'), new Header('sec-websocket-version', '13'), ], ); // After the server responds with 200, the stream becomes a bidirectional // byte tunnel. Use sendData/readEvent to exchange protocol-specific frames. ``` ## Alt-Svc and Origin Servers can advertise alternative service endpoints (RFC 7838) for protocol migration, and declare authoritative origins (RFC 8336) for connection coalescing. ```php use Psl\H2; /** @var H2\ServerConnectionInterface $server */ // Alt-Svc (RFC 7838) advertises alternative service endpoints, // enabling HTTP/2 to HTTP/3 migration. $server->sendAltSvc(0, 'https://example.com', 'h3=":443"; ma=2592000'); // ORIGIN (RFC 8336) declares which origins the server is authoritative for, // enabling connection coalescing across multiple domains. $server->sendOrigin([ 'https://example.com', 'https://cdn.example.com', 'https://api.example.com', ]); ``` ## Configuration The unified `Configuration` class replaces the deprecated `ServerConfiguration` and `ClientConfiguration`. It provides immutable builder patterns with `with*` methods and supports BDP auto-tuning for both client and server connections. When `maxReceiveWindowSize` is set, the connection dynamically sizes its receive window based on measured throughput and RTT using PING round-trips. Both `ClientConnection` and `ServerConnection` accept the new `Configuration` alongside their legacy types for backwards compatibility. ## Extension Frames Beyond the core 10 frame types, the component supports: - **ALTSVC** (0xa) -- RFC 7838 alternative service advertisement - **ORIGIN** (0xc) -- RFC 8336 authoritative origin declaration - **PRIORITY_UPDATE** (0x10) -- RFC 9218 extensible priority scheme Unknown frame types are silently ignored per the RFC, ensuring forward compatibility. See [src/Psl/H2/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/h2/src/Psl/H2/) for the full API. --- # HPACK The `HPACK` component implements RFC 7541 header compression for HTTP/2. It provides an `Encoder` and `Decoder` that compress and decompress HTTP header fields using static table lookups, dynamic table indexing, and Huffman coding. ## Encoding and Decoding Both encoder and decoder are stateful - they maintain a dynamic table that evolves across multiple calls within the same HTTP/2 connection. Each connection should use its own instances. ```php use Psl\HPACK; use Psl\IO; // Encode request headers $encoder = new HPACK\Encoder(); $encoded = $encoder->encode([ new HPACK\Header(':method', 'GET'), new HPACK\Header(':path', '/'), new HPACK\Header(':scheme', 'https'), new HPACK\Header(':authority', 'example.com'), new HPACK\Header('accept', 'text/html'), ]); IO\write_line('Encoded %d bytes', strlen($encoded)); // Decode on the other side $decoder = new HPACK\Decoder(); $headers = $decoder->decode($encoded); foreach ($headers as $header) { IO\write_line('%s: %s', $header->name, $header->value); } ``` ## Sensitive Headers Headers marked as sensitive (e.g. authorization, cookies) are encoded with the "never indexed" representation, preventing intermediary proxies from caching them in their dynamic tables. ```php use Psl\HPACK; use Psl\IO; // Sensitive headers are encoded with "never indexed" representation, // preventing intermediary proxies from caching them in their dynamic tables. $encoder = new HPACK\Encoder(); $encoded = $encoder->encode([ new HPACK\Header(':method', 'POST'), new HPACK\Header(':path', '/login'), new HPACK\Header(':scheme', 'https'), new HPACK\Header(':authority', 'example.com'), new HPACK\Header('authorization', 'Bearer secret-token', sensitive: true), new HPACK\Header('cookie', 'session=abc123', sensitive: true), ]); $decoder = new HPACK\Decoder(); $headers = $decoder->decode($encoded); foreach ($headers as $header) { $flag = $header->sensitive ? ' [sensitive]' : ''; IO\write_line('%s: %s%s', $header->name, $header->value, $flag); } ``` ## Response Encoding `encodeWithStatus()` is a convenience method for HTTP/2 response encoding. It prepends the `:status` pseudo-header and benefits from static table indexing for common status codes. ```php use Psl\HPACK; use Psl\IO; // encodeWithStatus() is optimized for HTTP/2 response encoding. // It prepends the :status pseudo-header in one pass without // allocating an intermediate array. $encoder = new HPACK\Encoder(); $encoded = $encoder->encodeWithStatus('200', [ new HPACK\Header('content-type', 'application/json'), new HPACK\Header('cache-control', 'no-cache'), ]); $decoder = new HPACK\Decoder(); $headers = $decoder->decode($encoded); foreach ($headers as $header) { IO\write_line('%s: %s', $header->name, $header->value); } // Output: // :status: 200 // content-type: application/json // cache-control: no-cache ``` ## Table Management The dynamic table size can be adjusted at runtime via `resize()`. This is typically driven by the HTTP/2 SETTINGS_HEADER_TABLE_SIZE parameter negotiated between peers. The maximum decompressed header list size is configurable via the constructor or `setMaxHeaderListSize()`. Headers that exceed this limit throw `HeaderListSizeException`. See [src/Psl/HPACK/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/hpack/src/Psl/HPACK/) for the full API. --- # HTTP Client The `HTTP Client` component provides an async HTTP client with connection pooling, HTTP/2 multiplexing, automatic protocol negotiation via ALPN, middleware support, and composable decorators for redirects and retries. All I/O is non-blocking and built on the PSL async runtime. ## Basic Usage Create a client, build a request, and send it. The returned transaction contains the response with status, headers, and a streaming body. ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\URL; $client = new Client\Client(); $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com')); $tx = $client->send($request); $tx->response->status; $tx->response->protocolVersion; $tx->response->headers->get('content-type'); $tx->response->body?->readAll(); ``` ## Sending Data Send a POST request with a JSON body and custom headers using `IO\MemoryHandle` for the request body. ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\IO; use Psl\URL; $client = new Client\Client(); $request = new Message\Request( method: Message\METHOD_POST, url: URL\parse('https://httpbin.org/post'), headers: Message\FieldMap::from([ ['Content-Type', 'application/json'], ['Accept', 'application/json'], ['Authorization', 'Bearer tok_example'], ]), body: new IO\MemoryHandle('{"name": "Alice", "email": "alice@example.com"}'), ); $tx = $client->send($request); $tx->response->status; $tx->response->body?->readAll(); ``` ## Concurrent Requests Use `Async\concurrently()` to send multiple requests in parallel over the same client. HTTP/2 connections are automatically multiplexed. ```php use Psl\Async; use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\URL; $client = new Client\Client(); $transactions = Async\concurrently([ 'users' => static fn() => $client->send(new Message\Request( method: Message\METHOD_GET, url: URL\parse('https://httpbin.org/get?resource=users'), )), 'posts' => static fn() => $client->send(new Message\Request( method: Message\METHOD_GET, url: URL\parse('https://httpbin.org/get?resource=posts'), )), 'comments' => static fn() => $client->send(new Message\Request( method: Message\METHOD_GET, url: URL\parse('https://httpbin.org/get?resource=comments'), )), ]); $transactions['users']->response->status; $transactions['posts']->response->body?->readAll(); ``` ## Redirects `RedirectClient` wraps any client and automatically follows 3xx redirects with credential stripping on cross-origin hops and method rewriting per RFC 9110. ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\URL; $client = new Client\RedirectClient(new Client\Client(), maxRedirects: 5, autoReferrer: true); $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://httpbin.org/redirect/3')); $tx = $client->send($request); $tx->response->status; ``` ## Retries `RetryClient` wraps any client and retries idempotent requests on transport-level failures with exponential backoff. ```php use Psl\DateTime\Duration; use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\URL; $client = new Client\RetryClient( new Client\RedirectClient(new Client\Client()), maxAttempts: 3, backoff: Duration::milliseconds(200), backoffMultiplier: 2, ); $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com/api/data')); $tx = $client->send($request); $tx->response->status; ``` ## Configuration `ClientConfiguration` controls TLS settings, protocol version preferences, response size limits, base URL, and proxy configuration. ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\HTTP\Message\ProtocolVersion; use Psl\TLS; use Psl\URL; $configuration = new Client\ClientConfiguration( maxResponseHeaderSize: 16_384, maxResponseBodySize: 50_000_000, baseUrl: URL\parse('https://api.example.com/v2'), tlsConfiguration: new TLS\ClientConfiguration(minimumVersion: TLS\Version::Tls12), protocolVersions: [ProtocolVersion::V20, ProtocolVersion::V11], ); $client = new Client\Client(configuration: $configuration); $request = new Message\Request(method: Message\METHOD_GET, url: null, requestTarget: '/users'); $tx = $client->send($request); $tx->response->status; ``` ## Per-Request Overrides `SendConfiguration` overrides specific client settings for a single request without affecting the client default. ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\HTTP\Message\ProtocolVersion; use Psl\URL; $client = new Client\Client(); $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com/large-file.tar.gz')); $tx = $client->send( $request, new Client\SendConfiguration(maxResponseBodySize: 500_000_000, protocolVersions: [ProtocolVersion::V11]), ); $tx->response->status; $tx->response->body?->readAll(); ``` ## SSRF Protection `DeniedDestinationsMiddleware` inspects the resolved peer IP after connection establishment and blocks requests to private, loopback, and link-local addresses. ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\URL; $client = new Client\Client(middleware: [Client\Middleware\DeniedDestinationsMiddleware::forPrivateNetworkRanges()]); $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com')); $tx = $client->send($request); $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('http://169.254.169.254/metadata')); $tx = $client->send($request); ``` ## Proxy Modes The client supports three proxy modes, configured via `ClientConfiguration` or overridden per-request via `SendConfiguration`: - **No proxy** (default): The client connects directly to the target server. - **HTTP proxy** (`$proxyConfiguration`): Configured via a `ProxyConfiguration` object that holds the proxy URL, optional `Proxy-Authorization` header, TLS SNI hostname, and bypass list. For HTTPS targets, the client connects to the proxy and issues an HTTP/1.1 CONNECT request to create a tunnel. For plain HTTP targets, the client connects to the proxy directly and uses absolute-form request targets per RFC 7230 Section 5.3.2 (forward proxying). The `$skipProxyFor` list on the configuration specifies hosts that bypass the proxy entirely. - **SOCKS5 proxy** (`$socksConfiguration`): All TCP connections are routed through the SOCKS5 proxy server. TLS, ALPN negotiation, and HTTP framing happen on top of the tunneled connection unchanged. Both proxy types can be combined: when both `$socksConfiguration` and `$proxyConfiguration` are set, the SOCKS5 proxy tunnels the TCP connection to the HTTP proxy, which then handles HTTP forward proxying or CONNECT tunneling. ## SOCKS Proxy Route all TCP connections through a SOCKS5 proxy using `$socksConfiguration`. TLS and ALPN negotiation happen on top of the tunneled connection. ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\Socks; use Psl\URL; $configuration = new Client\ClientConfiguration(socksConfiguration: new Socks\Configuration( proxyHost: 'proxy.example.com', proxyPort: 1080, username: 'user', password: 'secret', // @mago-expect lint:no-literal-password )); $client = new Client\Client(configuration: $configuration); $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com')); $tx = $client->send($request); $tx->response->status; ``` ## HTTP Proxy Use `$proxyConfiguration` to route requests through an HTTP proxy. Configure the proxy URL, authentication, and bypass rules via `ProxyConfiguration`. HTTPS targets use CONNECT tunneling while plain HTTP targets use forward proxying with absolute-form request targets. ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\URL; $configuration = new Client\ClientConfiguration( proxyConfiguration: new Client\ProxyConfiguration( URL\parse('http://proxy.example.com:8080'), skipProxyFor: [ 'localhost', '.internal.example.com', ], ), ); $client = new Client\Client(configuration: $configuration); $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com')); $tx = $client->send($request); $tx->response->status; ``` ## HTTP/2 over Plaintext (h2c) For servers that support HTTP/2 without TLS, set `protocolVersions` to only `ProtocolVersion::V20`. The client uses prior-knowledge mode per RFC 9113 Section 3.4 - it sends the HTTP/2 connection preface directly on the TCP connection, skipping TLS and ALPN negotiation entirely. ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\URL; $client = new Client\Client( configuration: new Client\ClientConfiguration(protocolVersions: [Message\ProtocolVersion::V20]), ); $tx = $client->send(new Message\Request(method: Message\METHOD_GET, url: URL\parse('http://localhost:8080/'))); $tx->response->protocolVersion; // ProtocolVersion::V20 $tx->response->status; // 200 $tx->response->body?->readAll(); ``` ## Request Body Rules TRACE requests that include a body are rejected with a `RequestException` per RFC 9110 Section 9.3.8. GET and HEAD requests are allowed to carry a body (useful for Elasticsearch-style query bodies), although this is not recommended per RFC 9110 Section 9.3.1. ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\IO; use Psl\URL; $client = new Client\Client(); // TRACE requests with a body throw RequestException per RFC 9110 Section 9.3.8 try { $client->send(new Message\Request( method: Message\METHOD_TRACE, url: URL\parse('https://example.com'), body: new IO\MemoryHandle('trace body'), )); } catch (Client\Exception\RequestException $e) { // "TRACE requests must not include a body." $e->getMessage(); } // TRACE without a body is allowed $tx = $client->send(new Message\Request(method: Message\METHOD_TRACE, url: URL\parse('https://example.com'))); // GET with a body is allowed (useful for Elasticsearch-style query bodies) // but not recommended per RFC 9110 Section 9.3.1 $tx = $client->send(new Message\Request( method: Message\METHOD_GET, url: URL\parse('https://elasticsearch.example.com/_search'), headers: new Message\FieldMap([['content-type', 'application/json']]), body: new IO\MemoryHandle('{"query":{"match_all":{}}}'), )); $tx->response->status; $tx->response->body?->readAll(); ``` ## Connection Timeout `SendConfiguration::$connectionTimeout` sets the maximum duration for establishing a connection (TCP handshake + TLS handshake) on a per-request basis. When set, the connector's cancellation token is linked with a timeout token scoped to this duration. The overall request cancellation token still applies independently. ```php use Psl\Async; use Psl\DateTime\Duration; use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\URL; $client = new Client\Client(); $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com')); // Limit the connection phase (TCP + TLS handshake) to 5 seconds. // The overall request cancellation token still applies independently. try { $tx = $client->send($request, new Client\SendConfiguration(connectionTimeout: Duration::seconds(5))); $tx->response->status; // 200 } catch (Async\Exception\CancelledException) { // @mago-expect lint:no-empty-catch-clause - Connection took longer than 5 seconds } ``` ## Callbacks `SendConfiguration` provides two per-request callbacks for observing connection and protocol events: - `onConnection(ConnectionMetadata): void` fires immediately after a connection is acquired (fresh or pooled), before the HTTP exchange. Use it to inspect the peer IP, local address, and TLS negotiation state. - `onInformationalResponse(Response): void` fires for each 1xx response as it arrives (100 Continue, 103 Early Hints, etc.). `ClientConfiguration` also accepts an `onInformationalResponse` callback that applies to all requests. When both the client-level and per-request callbacks are set, the client-level callback fires first, followed by the per-request callback. ```php use Psl\HTTP\Client; use Psl\HTTP\Client\Connection\ConnectionMetadata; use Psl\HTTP\Message; use Psl\URL; $client = new Client\Client(); $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com')); $tx = $client->send( $request, new Client\SendConfiguration( onConnection: static function (ConnectionMetadata $metadata): void { // Peer address is the resolved IP after DNS resolution $metadata->peerAddress->host; // e.g. "93.184.216.34" $metadata->peerAddress->port; // e.g. 443 // Local address is the ephemeral socket on this machine $metadata->localAddress->host; // e.g. "192.168.1.100" $metadata->localAddress->port; // e.g. 52431 // TLS state is available for HTTPS connections if ($metadata->tlsState !== null) { $metadata->tlsState->version; // e.g. TLS\Version::Tls13 $metadata->tlsState->cipherName; // e.g. "TLS_AES_256_GCM_SHA384" $metadata->tlsState->alpnProtocol; // e.g. "h2" $metadata->tlsState->peerCertificate; // server certificate } }, onInformationalResponse: static function (Message\Response $response): void { // Fires for each 1xx response (100 Continue, 103 Early Hints, etc.) $response->status; // e.g. 103 $response->headers->getAll('link'); // e.g. ["; rel=preload; as=style"] }, ), ); $tx->response->status; ``` ## Informational Responses HTTP servers may send one or more 1xx informational responses before the final response. These are delivered in two ways: 1. The `onInformationalResponse` callback (on `ClientConfiguration` or `SendConfiguration`) fires as each 1xx response arrives. 2. All 1xx responses are collected in `Transaction::$informational` in chronological order. Common informational responses include 100 Continue (the server is ready for the request body), 102 Processing (WebDAV, long-running operation), and 103 Early Hints (preliminary headers for resource preloading per RFC 8297). ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\URL; // Client-level callback fires for all requests $client = new Client\Client( configuration: new Client\ClientConfiguration(onInformationalResponse: static function (Message\Response $response): void { if ($response->status === 103) { // Extract Link headers from 103 Early Hints for resource preloading foreach ($response->headers->getAll('link') as $link) { // e.g. "; rel=preload; as=style" // Begin preloading the linked resource $link; } } }), ); $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com')); // Per-request callback fires after the client-level callback $tx = $client->send($request, new Client\SendConfiguration(onInformationalResponse: static function (Message\Response $response): void { // This fires after the client-level callback for each 1xx response if ($response->status === 100) { // Server acknowledged Expect: 100-continue, body will be sent } })); // Informational responses are also collected in the transaction foreach ($tx->informational as $info) { $info->status; // 100, 102, 103, etc. $info->headers; // headers from the informational response } $tx->response->status; // final 2xx-5xx response ``` ## Connection Metadata `ConnectionMetadata` captures the transport-level details of a connection at acquisition time. It is available through the `onConnection` callback on `SendConfiguration`. | Property | Type | Description | |----------|------|-------------| | `localAddress` | `Network\Address` | Local socket endpoint (IP and ephemeral port) | | `peerAddress` | `Network\Address` | Resolved remote address after DNS resolution | | `tlsState` | `TLS\ConnectionState` or `null` | TLS session details for HTTPS, `null` for plaintext | The `TLS\ConnectionState` includes `version`, `cipherName`, `cipherBits`, `alpnProtocol`, `peerCertificate`, and `peerCertificateChain`. ## Configuration Merging `SendConfiguration` fields override the client's `ClientConfiguration` on a per-request basis via `ClientConfiguration::withOverrides()`. Null fields in `SendConfiguration` inherit from the client default; non-null fields replace the client value for that request only. The following fields are overridable per-request: | Field | Overridable | |-------|-------------| | `maxResponseHeaderSize` | Yes | | `maxResponseBodySize` | Yes | | `baseUrl` | Yes | | `tlsConfiguration` | Yes | | `protocolVersions` | Yes | | `proxyConfiguration` | Yes | | `onInformationalResponse` | Yes (merged, not replaced) | | `connectionTimeout` | Per-request only (not on `ClientConfiguration`) | | `onConnection` | Per-request only (not on `ClientConfiguration`) | | `socksConfiguration` | No (client-only) | | `unixSocket` | No (client-only) | | `h2ClientConfiguration` | No (client-only) | > The `onInformationalResponse` callback receives special treatment: when both the client and send configuration define one, they are composed into a single callback that invokes the client-level callback first, then the per-request callback. > All other overridable fields, including `proxyConfiguration`, are fully replaced by the per-request value when set. For example, setting `proxyConfiguration` on `SendConfiguration` completely overwrites the client-level proxy settings; they are not merged. ## Middleware Connection-level middleware runs after the connection is established but before the HTTP exchange. Middleware has access to the `ConnectionInterface`, including the resolved peer address and TLS state. This enables security checks (SSRF protection), logging, metrics, and request/response transformation. Middleware must do one of two things: 1. **Delegate**: Call `$handler->handle(...)` to continue the chain. The handler calls `finalize()` internally. 2. **Short-circuit**: Return a `Transaction` directly. When short-circuiting, you MUST call `$connection->finalize()` on the transaction before returning it. Without finalization, the underlying connection is never released back to the pool, causing a connection leak. Middleware is registered via the `Client` constructor and applied in order: the first middleware in the list is the outermost (executed first). ```php use Psl\Async\CancellationTokenInterface; use Psl\Async\NullCancellationToken; use Psl\DateTime; use Psl\HTTP\Client; use Psl\HTTP\Client\Connection\ConnectionInterface; use Psl\HTTP\Client\Handler\HandlerInterface; use Psl\HTTP\Client\Middleware\MiddlewareInterface; use Psl\HTTP\Message; use Psl\URL; // Middleware that delegates to the next handler (pass-through with logging) final readonly class TimingMiddleware implements MiddlewareInterface { public function process( ConnectionInterface $connection, Message\Request $request, Client\ClientConfiguration $configuration, HandlerInterface $handler, CancellationTokenInterface $cancellation = new NullCancellationToken(), ): Message\Transaction { $start = DateTime\Timestamp::monotonic(); // Delegate to the next handler. The handler calls finalize() internally. $transaction = $handler->handle($connection, $request, $configuration, $cancellation); $elapsed = DateTime\Timestamp::monotonic()->since($start)->toString(); // Log: "GET https://example.com completed in 42.5ms" return $transaction; } } // Middleware that short-circuits the chain (returns a cached response) final readonly class CacheMiddleware implements MiddlewareInterface { public function __construct( /** @var array */ private array $cache = [], ) {} public function process( ConnectionInterface $connection, Message\Request $request, Client\ClientConfiguration $configuration, HandlerInterface $handler, CancellationTokenInterface $cancellation = new NullCancellationToken(), ): Message\Transaction { $cacheKey = $request->method . ':' . $request->requestTarget; if (isset($this->cache[$cacheKey])) { // Short-circuit: MUST call finalize() to release the connection back to the pool return $connection->finalize(new Message\Transaction([], null, $this->cache[$cacheKey])); } // No cache hit, delegate to the next handler return $handler->handle($connection, $request, $configuration, $cancellation); } } $client = new Client\Client(middleware: [ new TimingMiddleware(), new CacheMiddleware(), ]); $tx = $client->send(new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com'))); $tx->response->status; ``` ## HTTP/1.0 Mode Setting `protocolVersions` to `[ProtocolVersion::V10]` sends HTTP/1.0 requests. Connection: close is implicit per RFC 1945, so each request uses a separate TCP connection. Keep-alive and chunked transfer encoding are not available. Trailers are not supported. ```php use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\URL; // Force HTTP/1.0 for legacy server compatibility. // Connection: close is implicit; no keep-alive, no chunked transfer encoding. $client = new Client\Client( configuration: new Client\ClientConfiguration(protocolVersions: [Message\ProtocolVersion::V10]), ); $tx = $client->send(new Message\Request( method: Message\METHOD_GET, url: URL\parse('http://legacy-server.example.com/status'), )); $tx->response->protocolVersion; // ProtocolVersion::V10 $tx->response->status; $tx->response->body?->readAll(); ``` ## Trailers Request trailers are sent via `Request::$trailers`, an `Awaitable` that resolves after the body. Response trailers are available via `Response::$trailers`, which resolves after the response body is fully consumed. Trailers require chunked transfer encoding (HTTP/1.1) or HTTP/2 DATA frames. They are not available with HTTP/1.0. Declare expected trailer field names using the `Trailer` header. ```php use Psl\Async; use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\IO; use Psl\URL; $client = new Client\Client(); // Sending request trailers using an Awaitable. // Trailers are sent after the body with chunked transfer encoding (H1) or after DATA frames (H2). $deferred = new Async\Deferred(); $request = new Message\Request( method: Message\METHOD_POST, url: URL\parse('https://example.com/upload'), headers: new Message\FieldMap([ ['content-type', 'application/octet-stream'], ['trailer', 'checksum'], ]), body: new IO\MemoryHandle('file contents here'), trailers: $deferred->getAwaitable(), ); // Resolve the trailers after the body is ready to be sent. // In practice, you would compute the checksum while streaming the body. $deferred->complete(new Message\FieldMap([['checksum', 'sha256=abc123...']])); $tx = $client->send($request); // Reading response trailers. Trailers are available after the body is fully consumed. $body = $tx->response->body?->readAll(); if ($tx->response->trailers !== null) { $trailerFields = $tx->response->trailers->await(); $trailerFields->get('server-timing'); // e.g. "db;dur=53" } ``` ## Connection Pooling The default `PooledConnector` manages connection reuse across requests: - **HTTP/1.x**: Idle connections are pooled per origin (scheme + host + port) with LIFO checkout. When a request completes and the body is fully consumed, the underlying TCP/TLS stream is returned to the pool. A global cap of 256 idle connections and a per-host cap of 32 prevent unbounded memory growth. - **HTTP/2**: A single multiplexed connection per origin is shared across concurrent requests. Each request opens one HTTP/2 stream on the shared connection. If the connection is closed (GOAWAY frame or connection error), it is removed and a new connection is established on the next request. - **Connection coalescing**: When multiple fibers concurrently request connections to the same HTTPS origin, the first fiber performs the TLS handshake. If HTTP/2 is negotiated, waiting fibers reuse the session instead of opening redundant connections. If HTTP/1.1 is negotiated, waiting fibers establish their own connections. ## Pool Release and Finalize For HTTP/1.x keep-alive connections, the pooled connection is not released until the response body is fully consumed. The response body handle defers the pool release until EOF or close. This prevents a second request from reusing a connection while the first response body is still being read. This is why middleware that short-circuits the handler chain MUST call `$connection->finalize()`: finalize wraps the response body (if any) so the connection is properly returned to the pool. Without it, the connection leaks. For HTTP/2 connections, stream lifecycle is managed independently, so finalize returns the transaction as-is. ## Error Handling The client throws a structured exception hierarchy. Transport-level exceptions from `Psl\Network` and `Psl\IO` propagate unwrapped. ```php use Psl\Async; use Psl\HTTP\Client; use Psl\HTTP\Message; use Psl\IO; use Psl\Network; use Psl\URL; $client = new Client\RedirectClient(new Client\Client()); try { $tx = $client->send(new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com'))); $tx->response->status; } catch (Client\Exception\RequestException $e) { IO\write_line('Invalid request: %s', $e->getMessage()); } catch (Client\Exception\ProtocolException $e) { IO\write_line('Bad response: %s', $e->getMessage()); } catch (Client\Exception\TooManyRedirectsException $e) { IO\write_line('Redirect loop: %s', $e->getMessage()); } catch (Network\Exception\RuntimeException $e) { IO\write_line('Connection failed: %s', $e->getMessage()); } catch (IO\Exception\RuntimeException $e) { IO\write_line('I/O error: %s', $e->getMessage()); } catch (Async\Exception\CancelledException) { IO\write_line('Request cancelled'); } ``` ## Exception Hierarchy | Exception | When Thrown | |-----------|------------| | `Exception\RuntimeException` | Base class for all HTTP client errors | | `Exception\RequestException` | Request is invalid (missing URL, malformed target, TRACE with body) | | `Exception\ProtocolException` | Malformed response, unsupported protocol, body size exceeded | | `Exception\TooManyRedirectsException` | Redirect limit exceeded (via `RedirectClient`) | | `Network\Exception\RuntimeException` | Connection refused, DNS failure, connect timeout | | `IO\Exception\RuntimeException` | Read/write failure on the underlying socket | | `Async\Exception\CancelledException` | Cancellation token fired during any stage | ## Protocol Support | Version | Transport | Negotiation | Multiplexing | Status | |---------|-----------|-------------|--------------|--------| | HTTP/1.0 | TCP / TLS | Explicit | No | Supported | | HTTP/1.1 | TCP / TLS | Default fallback | No (persistent connections) | Supported | | HTTP/2 | TLS (h2) / TCP (h2c) | ALPN | Yes (streams over single connection) | Supported | | HTTP/3 | QUIC | ALPN | Yes (independent streams, no HOL blocking) | Planned | > HTTP/3 support is not yet available. The client's connector and connection abstractions are designed to accommodate QUIC-based transports in the future without breaking the public API. ## RFC Compliance | RFC | Coverage | |-----|----------| | RFC 1945 | HTTP/1.0 message format | | RFC 9110 | HTTP semantics, methods, status codes, redirects, idempotency | | RFC 9112 | HTTP/1.1 message syntax, chunked transfer encoding, persistent connections | | RFC 9113 | HTTP/2 binary framing, HPACK, flow control, server push, multiplexing | | RFC 9114 | HTTP/3 over QUIC (planned, not yet implemented) | | RFC 8297 | 103 Early Hints informational responses | | RFC 3986 | URI resolution for base URL and redirect Location headers | | RFC 1928 | SOCKS5 proxy protocol | | RFC 7231 | Historical redirect method rewriting (301/302 to GET) | See [src/Psl/HTTP/Client/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/http-message/src/Psl/HTTP/Client/) for the full API. --- # HTTP Message The `HTTP Message` component provides version-agnostic HTTP message abstractions. Requests, responses, and headers work identically across HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3. Bodies are streaming (`ReadHandleInterface`), trailers are async (`Awaitable`), and all objects are immutable. ## Requests Construct a request with a method, URL, and optional headers/body. The request target is derived from the URL automatically. ```php use Psl\HTTP\Message; use Psl\IO; use Psl\URL; $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com/api/users?page=1')); $request->method; // "GET" $request->requestTarget; // "/api/users?page=1" $request->url?->authority->host; // "example.com" $request->protocolVersion; // ProtocolVersion::V11 $post = new Message\Request( method: Message\METHOD_POST, url: URL\parse('https://example.com/api/users'), headers: Message\FieldMap::from([ ['Content-Type', 'application/json'], ['Accept', 'application/json'], ]), body: new IO\MemoryHandle('{"name": "Alice"}'), ); ``` ## Responses Responses carry a status code, headers, and an optional streaming body. Reason phrases are not stored on the response since HTTP/2 and HTTP/3 do not transmit them. ```php use Psl\HTTP\Message; use Psl\IO; $response = new Message\Response( status: Message\STATUS_OK, headers: Message\FieldMap::from([ ['Content-Type', 'text/html; charset=utf-8'], ]), body: new IO\MemoryHandle('

Hello

'), ); $response->status; // 200 $response->headers->get('Content-Type'); // "text/html; charset=utf-8" Message\reason_phrase($response->status); // "OK" Message\reason_phrase(Message\STATUS_NOT_FOUND); // "Not Found" ``` ## Header Fields `FieldMap` is an ordered, case-insensitive collection of header field pairs. It supports multiple values for the same name (e.g. Set-Cookie) and preserves insertion order and original casing. ```php use Psl\HTTP\Message\FieldMap; use Psl\IO; $headers = FieldMap::from([ ['Content-Type', 'application/json'], ['Accept', 'text/html'], ['Accept', 'application/json'], ['X-Request-Id', 'abc-123'], ]); $headers->get('content-type'); // "application/json" (case-insensitive) $headers->getAll('Accept'); // ["text/html", "application/json"] $headers->has('X-Request-Id'); // true $updated = $headers ->with('Content-Type', 'text/plain') ->withAdded('Cache-Control', 'no-cache') ->without('X-Request-Id'); $updated->get('Content-Type'); // "text/plain" $updated->has('X-Request-Id'); // false foreach ($headers as [$name, $value]) { // Iterates in insertion order, preserving original casing IO\write_line('%s: %s', $name, $value); } ``` ## Immutable Mutation All `with*()` methods return new instances. The original message is never modified. ```php use Psl\HTTP\Message; use Psl\IO; use Psl\URL; $request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com/')); $modified = $request ->withMethod(Message\METHOD_POST) ->withUrl(URL\parse('https://example.com/api/submit')) ->withHeader('Content-Type', 'application/json') ->withHeader('Authorization', 'Bearer token123') ->withBody(new IO\MemoryHandle('{"data": true}')) ->withProtocolVersion(Message\ProtocolVersion::V20); $modified->method; // "POST" $modified->protocolVersion; // ProtocolVersion::V20 $request->method; // "GET" (original unchanged) ``` ## Streaming Bodies Bodies are modeled as `ReadHandleInterface` streams, allowing constant-memory processing of arbitrarily large payloads. Use `MemoryHandle` to create a body from a string. ```php use Psl\HTTP\Message; use Psl\IO; use Psl\URL; $request = new Message\Request( method: Message\METHOD_POST, url: URL\parse('https://example.com/upload'), headers: Message\FieldMap::from([ ['Content-Type', 'application/json'], ]), body: new IO\MemoryHandle('{"name": "Alice", "role": "admin"}'), ); $body = $request->body; $body?->tryRead(16); // '{"name": "Alice"' (non-blocking, up to 16 bytes) $body?->read(maxBytes: 8); // ', "role"' (suspends until data available) $response = new Message\Response( status: Message\STATUS_OK, body: new IO\MemoryHandle('Hello, world!'), ); $response->body?->readAll(); // 'Hello, world!' ``` ## Trailers Trailing header fields arrive after the body has been fully transmitted. They are modeled as `Awaitable` because the values are not known until the body is complete. Use `Deferred` to create trailers that are resolved later. ```php use Psl\Async; use Psl\HTTP\Message; use Psl\IO; use Psl\URL; /** @var Async\Deferred $deferred */ $deferred = new Async\Deferred(); $request = new Message\Request( method: Message\METHOD_POST, url: URL\parse('https://example.com/upload'), headers: Message\FieldMap::from([ ['Trailer', 'Checksum'], ]), body: new IO\MemoryHandle('file contents here'), trailers: $deferred->getAwaitable(), ); $deferred->complete(Message\FieldMap::from([ ['Checksum', 'sha256=abc123...'], ])); $trailerMap = $request->trailers?->await(); $trailerMap?->get('Checksum'); // "sha256=abc123..." ``` ## Transactions `Transaction` groups the final response with any informational (1xx) responses received before it and any HTTP/2 server-pushed exchanges. This is the return type of connection-level exchange methods. ```php use Psl\HTTP\Message; use Psl\IO; $earlyHints = new Message\Response(status: Message\STATUS_EARLY_HINTS, headers: Message\FieldMap::from([ ['Link', '; rel=preload; as=style'], ])); $finalResponse = new Message\Response( status: Message\STATUS_OK, headers: Message\FieldMap::from([ ['Content-Type', 'text/html'], ]), body: new IO\MemoryHandle('

Hello

'), ); $tx = new Message\Transaction(informational: [$earlyHints], pushed: null, response: $finalResponse); $tx->response->status; // 200 $tx->informational[0]->status; // 103 $tx->informational[0]->headers->get('Link'); // "; rel=preload; as=style" $tx->pushed; // null (no HTTP/2 server push) ``` ## Status Codes and Methods Method and status code constants improve readability and prevent typos. The `reason_phrase()` function maps any status code to its canonical reason phrase for HTTP/1.x serialization. ```php use Psl\HTTP\Message; Message\METHOD_GET; // "GET" Message\METHOD_POST; // "POST" Message\METHOD_PUT; // "PUT" Message\METHOD_DELETE; // "DELETE" Message\METHOD_PATCH; // "PATCH" Message\STATUS_OK; // 200 Message\STATUS_CREATED; // 201 Message\STATUS_NO_CONTENT; // 204 Message\STATUS_NOT_FOUND; // 404 Message\STATUS_INTERNAL_SERVER_ERROR; // 500 Message\reason_phrase(Message\STATUS_OK); // "OK" Message\reason_phrase(Message\STATUS_NOT_FOUND); // "Not Found" Message\reason_phrase(Message\STATUS_TOO_MANY_REQUESTS); // "Too Many Requests" Message\reason_phrase(999); // "status code 999" ``` ## Protocol Versions | Version | Enum Case | Value | |---------|-----------|-------| | HTTP/1.0 | `ProtocolVersion::V10` | `HTTP/1.0` | | HTTP/1.1 | `ProtocolVersion::V11` | `HTTP/1.1` | | HTTP/2 | `ProtocolVersion::V20` | `HTTP/2` | | HTTP/3 | `ProtocolVersion::V30` | `HTTP/3` | ## Status Code Classes | Range | Class | Example | |-------|-------|---------| | 1xx | Informational | 100 Continue, 101 Switching Protocols | | 2xx | Successful | 200 OK, 201 Created, 204 No Content | | 3xx | Redirection | 301 Moved Permanently, 304 Not Modified | | 4xx | Client Error | 400 Bad Request, 404 Not Found, 429 Too Many Requests | | 5xx | Server Error | 500 Internal Server Error, 502 Bad Gateway | ## RFC Compliance | RFC | Coverage | |-----|----------| | RFC 9110 | HTTP semantics: methods, status codes, header fields, message body | | RFC 9112 | HTTP/1.1 message syntax (request line, status line, headers) | | RFC 9113 | HTTP/2 pseudo-headers, trailers, binary framing semantics | | RFC 9114 | HTTP/3 message mapping over QUIC | See [src/Psl/HTTP/Message/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/http-message/src/Psl/HTTP/Message/) for the full API. --- # Message The `Message` component implements RFC 5322 Internet Message construction, parsing, and serialization. It provides typed access to standard header fields (From, To, Subject, Date, Message-ID, etc.) with the message body represented as a MIME `PartInterface`. ## Construction Messages are built using the fluent `with*()` methods. Each returns a new immutable instance. Address methods (`withFrom`, `withTo`, `withCc`, `withBcc`, `withReplyTo`) accept a plain string, a `Mailbox`, or an `AddressList`. ```php use Psl\IO; use Psl\Message; use Psl\MIME\Part; // Addresses accept strings, Mailbox, or AddressList $message = new Message\Message() ->withFrom('Alice ') ->withTo('bob@example.com, carol@example.com') ->withSubject('Hello from PSL') ->withDate(Psl\DateTime\DateTime::now()) ->withGeneratedMessageId() ->withContent(new Part\Text(new IO\MemoryHandle('Hello, World!'))); $message->from; // AddressList $message->to; // AddressList $message->subject; // "Hello from PSL" $message->messageId; // MessageId $message->content; // PartInterface (the text part) ``` ## Multipart Bodies The message body is a `PartInterface` from the MIME component. Compose any MIME structure using `Part\Text`, `Part\Data`, and the multipart types. ```php use Psl\IO; use Psl\Message; use Psl\MIME\MediaType; use Psl\MIME\MultiPart; use Psl\MIME\Part; // Text + HTML alternatives $alt = new MultiPart\Alternative(); $alt->addPart(new Part\Text(new IO\MemoryHandle('Plain text version'))); $alt->addPart(new Part\Text(new IO\MemoryHandle('

HTML version

'), 'html')); // Wrap with an attachment $mixed = new MultiPart\Composite($alt); $mixed->addPart( new Part\Data( new IO\MemoryHandle('pdf content'), filename: 'report.pdf', mediaType: MediaType::parse('application/pdf'), ), ); $message = new Message\Message() ->withFrom('alice@example.com') ->withTo('bob@example.com') ->withSubject('Report attached') ->withContent($mixed); ``` ## Serialization and Parsing `serialize()` produces a streaming RFC 5322 representation. `parse()` accepts a string or a `ReadHandleInterface` for streaming input from network sockets. ```php use Psl\IO; use Psl\Message; use Psl\MIME\Part; $message = new Message\Message() ->withFrom('alice@example.com') ->withTo('bob@example.com') ->withSubject('Round trip') ->withContent(new Part\Text(new IO\MemoryHandle('Hello!'))); // Serialize to a streaming handle (headers + body) $handle = Message\serialize($message); // Parse back from a string or handle $parsed = Message\parse($handle); $parsed->subject; // "Round trip" $parsed->from?->mailboxes()[0]?->address; // "alice@example.com" $parsed->content->body()->readAll(); // encoded body content ``` ## Reply, Reply All, Forward Factory methods derive threading headers (In-Reply-To, References) and address fields from an original message per RFC 5322. ```php use Psl\IO; use Psl\Message; use Psl\Message\Address\Mailbox; use Psl\MIME\Part; $original = Message\parse( "From: alice@example.com\r\nTo: bob@example.com\r\nSubject: Hello\r\nMessage-ID: \r\n\r\nHi Bob!", ); $me = Mailbox::parse('bob@example.com'); // Reply sets To from original From, prefixes subject, sets threading headers $reply = Message\Message::reply($original, $me)->withContent(new Part\Text(new IO\MemoryHandle('Thanks Alice!'))); $reply->subject; // "Re: Hello" $reply->inReplyTo; // [MessageId("msg-001@example.com")] $reply->references; // [MessageId("msg-001@example.com")] // Also available: Message::replyAll() and Message::forward() ``` ## SMTP Envelope `Envelope` derives the transport-level sender (MAIL FROM) and recipients (RCPT TO) from message headers per RFC 5321. These may differ from the visible From/To/Cc headers. ```php use Psl\IO; use Psl\Message; use Psl\Message\Envelope; use Psl\MIME\Part; $message = new Message\Message() ->withFrom('alice@example.com') ->withTo('bob@example.com') ->withCc('carol@example.com') ->withBcc('dave@example.com') ->withContent(new Part\Text(new IO\MemoryHandle('Hello'))); // Derive SMTP envelope from message headers $envelope = Envelope::fromMessage($message); $envelope->sender; // Mailbox("alice@example.com") $envelope->recipients; // [bob, carol, dave] (To + Cc + Bcc flattened) ``` ## Addresses The `Address` namespace provides RFC 5322 compliant address parsing: - `Mailbox` represents a single email address with optional display name and RFC 5322 comments - `Group` represents a named group of mailboxes (`display-name : mailbox-list ;`) - `AddressList` holds an ordered list of mailboxes and groups, with merge and exclude operations All address types support RFC 2047 encoded-word display names for non-ASCII characters. ## RFC Compliance | RFC | Coverage | |-----|----------| | RFC 5322 | Message format, header fields, address syntax, threading | | RFC 5321 | SMTP envelope derivation | | RFC 2045 | MIME body integration via PartInterface | | RFC 2047 | Encoded-word subjects and display names | See [src/Psl/Message/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/message/src/Psl/Message/) for the full API. --- # MIME The `MIME` component implements the Multipurpose Internet Mail Extensions (RFC 2045-2049) and related standards. It provides media type parsing, MIME part construction, multipart body streaming, content sniffing, S/MIME cryptography, and DKIM signing - all without filesystem coupling or temporary files. ## Media Types `MediaType` is an immutable value object representing a MIME media type like `application/json` or `text/html; charset=utf-8`. Type and subtype are always lowercased. Structured syntax suffixes and registration trees are extracted automatically. ```php use Psl\MIME\MediaType; $type = MediaType::parse('application/vnd.api+json; charset=utf-8'); $type->type; // "application" $type->subtype; // "vnd.api+json" $type->suffix; // "json" $type->tree; // "vnd" $type->parameters; // Parameters with charset=utf-8 $type->essence(); // "application/vnd.api+json" // From file extension $json = MediaType::fromExtension('json'); // application/json // IANA registration $type->isRegistered(); // true $type->extensions(); // ["json"] ``` ## Content Negotiation `MediaPreferences` parses HTTP Accept headers and negotiates the best media type per RFC 9110. ```php use Psl\MIME\MediaPreferences; use Psl\MIME\MediaType; $prefs = MediaPreferences::parse('text/html, application/json;q=0.9, */*;q=0.1'); $best = $prefs->negotiate([ new MediaType('application', 'json'), new MediaType('text', 'html'), ]); // $best->essence() === "text/html" ``` ## Parts Parts are the building blocks of MIME messages. Every part implements `PartInterface`, which exposes `$mediaType`, `$headers`, and `body()`. ```php use Psl\IO; use Psl\MIME\Headers; use Psl\MIME\MediaType; use Psl\MIME\Part; // Raw part from headers + body $raw = new Part\Part(Headers::fromPairs([['Content-Type', 'text/plain']]), new IO\MemoryHandle('Hello')); // Text part with automatic transfer encoding (quoted-printable by default) $text = new Part\Text(new IO\MemoryHandle('Hello, World!')); $text->mediaType; // text/plain; charset=utf-8 // Binary attachment with base64 encoding $data = new Part\Data( new IO\MemoryHandle('binary content'), filename: 'report.pdf', mediaType: MediaType::parse('application/pdf'), ); // Convert attachment to inline with Content-ID $inline = $data->asInline(Psl\MIME\ContentId::generate()); ``` ## Multipart Bodies Multipart types compose multiple parts into a single body, delimited by a boundary string. All multipart types implement `MultiPartInterface` (which extends `PartInterface`), so they nest naturally. ```php use Psl\IO; use Psl\MIME\MediaType; use Psl\MIME\MultiPart; use Psl\MIME\Part; // multipart/alternative: text + HTML versions $alt = new MultiPart\Alternative(); $alt->addPart(new Part\Text(new IO\MemoryHandle('Plain text'))); $alt->addPart(new Part\Text(new IO\MemoryHandle('

HTML

'), subtype: 'html')); // multipart/mixed: body + attachments $mixed = new MultiPart\Composite($alt); $mixed->addPart( new Part\Data( new IO\MemoryHandle('pdf content'), filename: 'doc.pdf', mediaType: MediaType::parse('application/pdf'), ), ); // Nested multipart is just $mixed->body() - a streaming read handle // IO\copy($mixed->body(), $outputHandle); ``` ### Form Data `Form` builds multipart/form-data bodies per RFC 7578, for HTML form submissions and file uploads. ```php use Psl\IO; use Psl\MIME\MediaType; use Psl\MIME\MultiPart\Form; use Psl\MIME\Part; $form = new Form(); $form->addField('username', 'azjezz'); $form->addField('bio', 'PSL maintainer'); $form->addPart( 'avatar', new Part\Data(new IO\MemoryHandle('image bytes'), filename: 'avatar.png', mediaType: MediaType::parse('image/png')), ); $form->mediaType; // multipart/form-data; boundary=... $form->boundary; // random 24-char alphanumeric string ``` ### Streaming Parser The streaming parser yields parts lazily from a read handle, with configurable limits to prevent abuse. ```php use Psl\IO; use Psl\MIME\MultiPart\Parser; $body = "--boundary\r\nContent-Type: text/plain\r\n\r\nHello\r\n--boundary\r\nContent-Type: text/html\r\n\r\nWorld\r\n--boundary--\r\n"; $parser = new Parser(boundary: 'boundary', maxParts: 100, maxPartSize: 10_000_000, spoolThreshold: 2_097_152); foreach ($parser->parse(new IO\MemoryHandle($body)) as $part) { IO\write_line('%s: %s', $part->mediaType->essence(), $part->body()->readAll()); } ``` ## S/MIME Cryptography Sign, verify, encrypt, and decrypt using CMS (RFC 5652) - entirely in-memory without temporary files or `openssl_pkcs7_*` functions. ```php use Psl\MIME\SMIME; // Signing requires a certificate and private key in PEM format. // Verification can optionally check the certificate chain against trusted CAs. // Encryption requires the recipient's certificate; decryption requires their private key. // All operations are in-memory -- no temporary files or openssl_pkcs7_* calls. /** @var string $certPem */ /** @var string $keyPem */ /** @var string $caCertPem */ /** @var string $recipientCertPem */ /** @var string $recipientKeyPem */ // Sign and verify $signer = new SMIME\Signer($certPem, $keyPem); $signed = $signer->sign('Hello'); $verifier = new SMIME\Verifier([$caCertPem]); $result = $verifier->verify($signed, verifyCertificateChain: true); $result->valid; // true $result->content; // "Hello" $result->digestAlgorithm; // DigestAlgorithm::Sha256 // Encrypt and decrypt $encryptor = new SMIME\Encryptor([$recipientCertPem], SMIME\CipherAlgorithm::Aes256Cbc); $encrypted = $encryptor->encrypt('Secret'); $decryptor = new SMIME\Decryptor($recipientKeyPem); $decrypted = $decryptor->decrypt($encrypted); // "Secret" ``` ## DKIM Signing Sign raw MIME messages with DKIM (RFC 6376) using RSA-SHA256 or Ed25519-SHA256 (RFC 8463). RSA keys below 1024 bits are rejected per RFC 8301. ```php use Psl\MIME\DKIM; /** @var string $rsaPrivateKeyPem */ $config = new DKIM\SigningConfiguration( domain: 'example.com', selector: 'default', algorithm: DKIM\Algorithm::RsaSha256, headerCanonicalization: DKIM\Canonicalization::Relaxed, bodyCanonicalization: DKIM\Canonicalization::Relaxed, ); $signer = new DKIM\Signer($rsaPrivateKeyPem, $config); $rawMessage = "From: sender@example.com\r\nTo: rcpt@example.com\r\nSubject: Test\r\n\r\nHello!"; $signedMessage = $signer->sign($rawMessage); // $signedMessage now has a DKIM-Signature header prepended ``` ## Content Sniffing Detect MIME types from content bytes using magic signatures, RIFF sub-formats, ISO BMFF brands, and text heuristics. Works on raw byte strings or seekable read handles. ## RFC Compliance | RFC | Coverage | |-----|----------| | RFC 2045 | Media types, parameters, transfer encoding | | RFC 2046 | Multipart bodies, boundary rules | | RFC 2183 | Content-Disposition with safe filename | | RFC 2231 | Parameter encoding with continuations and charset | | RFC 2387 | multipart/related | | RFC 2392 | Content-ID parsing and generation | | RFC 5322 | Header line folding | | RFC 5652 | CMS signing, verification, encryption, decryption | | RFC 6376 | DKIM signing with simple/relaxed canonicalization | | RFC 6838 | Media type registration, suffix/tree extraction | | RFC 7578 | multipart/form-data | | RFC 8301 | DKIM minimum key size enforcement | | RFC 8463 | Ed25519-SHA256 DKIM signing | | RFC 8551 | S/MIME 4.0 message specification | | RFC 9110 | Content negotiation (Accept header) | See [src/Psl/MIME/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/mime/src/Psl/MIME/) for the full API. --- # SMTP The `SMTP` component implements an RFC 5321 SMTP client with connection pooling, TLS, authentication, and support for modern SMTP extensions. It integrates with the `Message` and `MIME` components for complete email sending. ## Sending a Message The `Transport` handles the full SMTP lifecycle: connection, EHLO, TLS, authentication, message transmission, and connection reuse. Pass an `Envelope` (derived from message headers) and a `MessageInterface` to send. ```php use Psl\IO; use Psl\Message\Address\Mailbox; use Psl\Message\Envelope; use Psl\Message\Message; use Psl\MIME\Part; use Psl\SMTP\Client\Transport; use Psl\SMTP\Client\TransportConfiguration; use Psl\SMTP\Security; $transport = new Transport( TransportConfiguration::default()->withHost('smtp.example.com')->withSecurity(Security::TLS), ); $sender = new Mailbox('alice', 'example.com', 'Alice'); $recipient = new Mailbox('bob', 'example.com'); $message = new Message() ->withFrom($sender) ->withTo($recipient) ->withSubject('Hello from PSL') ->withContent(new Part\Text(new IO\MemoryHandle('Hello, Bob!'))); $report = $transport->send(Envelope::fromMessage($message), $message); $transport->close(); ``` ## Authentication Five authentication mechanisms are supported. Pass any `AuthenticatorInterface` as the second argument to `Transport`. ```php use Psl\SMTP\Client\Authentication; use Psl\SMTP\Client\Transport; use Psl\SMTP\Client\TransportConfiguration; use Psl\SMTP\Security; // PLAIN authentication $plain = new Authentication\PlainAuthenticator('user', 'password'); // LOGIN authentication $login = new Authentication\LoginAuthenticator('user', 'password'); // XOAUTH2 for Gmail and other OAuth2-enabled servers $oauth = new Authentication\XOAuth2Authenticator('user@gmail.com', 'oauth2-access-token'); // CRAM-MD5 challenge-response (avoids sending password in plaintext) $cramMd5 = new Authentication\CRAMMD5Authenticator('user', 'password'); // SCRAM-SHA-256 with mutual authentication (RFC 7677) $scram = new Authentication\SCRAMSHA256Authenticator('user', 'password'); // Pass any authenticator as the second argument to Transport $transport = new Transport( TransportConfiguration::default()->withHost('smtp.gmail.com')->withSecurity(Security::TLS), $oauth, ); ``` ## Transport Configuration `TransportConfiguration` controls connection-level settings: host, port, security mode, pipelining, chunking, and TCP/TLS options. All settings are immutable with fluent `with*()` builders. ```php use Psl\SMTP\Client\TransportConfiguration; use Psl\SMTP\Security; use Psl\TCP; use Psl\TLS; // All transport settings are immutable with fluent with*() builders $config = TransportConfiguration::default() ->withHost('smtp.example.com') ->withPort(465) ->withSecurity(Security::TLS) ->withLocalHostname('client.example.com') ->withPipelining(true) ->withChunking(true) ->withChunkSize(32_768) ->withAllowPartialSuccess(false); // TCP and TLS settings are configurable independently $config = $config ->withConnectConfiguration(new TCP\ConnectConfiguration(noDelay: true)) ->withTlsClientConfiguration(new TLS\ClientConfiguration(peerVerification: true, allowSelfSigned: false)); ``` ## Send Configuration `SendConfiguration` controls per-send MAIL FROM extension parameters: DSN notifications, REQUIRETLS, message priority, delivery deadlines, and deferred delivery. ```php use Psl\DateTime\DateTime; use Psl\DateTime\Duration; use Psl\SMTP\Client\SendConfiguration; use Psl\SMTP\DeliverBy; use Psl\SMTP\DeliverByMode; use Psl\SMTP\Priority; // Per-send parameters control MAIL FROM extensions $config = new SendConfiguration() // DSN: request delivery status notifications (RFC 3461) ->withDsnReturn('FULL') ->withDsnEnvelopeId('msg-001') ->withDsnNotify('SUCCESS,FAILURE') // Require TLS for the entire delivery chain (RFC 8689) ->withRequireTls(true) // Message priority (RFC 6710 / STANAG 4406) ->withPriority(Priority::Urgent) // Delivery deadline: return if not delivered within 2 hours (RFC 2852) ->withDeliverBy(new DeliverBy(Duration::hours(2), DeliverByMode::Return)) // Deferred delivery: hold for 30 minutes (RFC 4865) ->withFutureRelease(Duration::minutes(30)); // Or hold until a specific time $config = $config->withFutureRelease(DateTime::now()->plusHours(6)); ``` ## Partial Recipient Success By default, the transport throws on the first rejected recipient. Enable `allowPartialSuccess` to deliver to accepted recipients and report failures via `DeliveryReport`. ```php use Psl\IO; use Psl\Message\Address\Mailbox; use Psl\Message\Envelope; use Psl\Message\Message; use Psl\MIME\Part; use Psl\SMTP\Client\Transport; use Psl\SMTP\Client\TransportConfiguration; use Psl\SMTP\Security; // Enable partial success to deliver to accepted recipients // even when some are rejected $transport = new Transport( TransportConfiguration::default() ->withHost('smtp.example.com') ->withSecurity(Security::TLS) ->withAllowPartialSuccess(true), ); $sender = new Mailbox('noreply', 'example.com'); $message = new Message() ->withFrom($sender) ->withTo('alice@example.com, bob@invalid.test, carol@example.com') ->withSubject('Team Update') ->withContent(new Part\Text(new IO\MemoryHandle('Hello team!'))); $report = $transport->send(Envelope::fromMessage($message), $message); // Check for rejected recipients if ($report->hasRejections()) { foreach ($report->rejectedRecipients as [$mailbox, $reply]) { // $mailbox->address: "bob@invalid.test" // $reply->code: 550 // $reply->message: "User not found" IO\write_line($reply->message); } } $transport->close(); ``` ## Async and Cancellation The transport is fully async-capable. Use `Async\concurrently` to send multiple messages in parallel with automatic connection pooling. Use cancellation tokens for timeouts. ```php use Psl\Async; use Psl\IO; use Psl\Message\Address\Mailbox; use Psl\Message\Envelope; use Psl\Message\Message; use Psl\MIME\Part; use Psl\SMTP\Client\Transport; use Psl\SMTP\Client\TransportConfiguration; use Psl\SMTP\Security; use Psl\Vec; $transport = new Transport( TransportConfiguration::default()->withHost('smtp.example.com')->withSecurity(Security::TLS), ); $sender = new Mailbox('noreply', 'example.com'); $recipients = ['alice@example.com', 'bob@example.com', 'carol@example.com']; // Send to all recipients concurrently, reusing pooled connections Async\concurrently(Vec\map($recipients, static fn(string $address): Closure => static function () use ( $transport, $sender, $address, ): void { $recipient = Mailbox::parse($address); $message = new Message() ->withFrom($sender) ->withTo($recipient) ->withSubject('Hello!') ->withContent(new Part\Text(new IO\MemoryHandle('Hi there!'))); $transport->send(Envelope::fromMessage($message), $message); })); // Send with a timeout using a cancellation token $message = new Message() ->withFrom($sender) ->withTo('dave@example.com') ->withSubject('Urgent') ->withContent(new Part\Text(new IO\MemoryHandle('Time-sensitive!'))); $transport->send( Envelope::fromMessage($message), $message, cancellation: new Async\TimeoutCancellationToken(Psl\DateTime\Duration::seconds(10)), ); $transport->close(); ``` ## Low-Level Connection `Connection` implements `Network\StreamInterface` and speaks raw SMTP commands. Use it for direct protocol interaction or to build custom transports. ```php use Psl\SMTP\Client\Connection; use Psl\SMTP\Command; use Psl\TCP; // Low-level connection for direct SMTP protocol interaction $stream = TCP\connect('smtp.example.com', 25); $connection = new Connection($stream); // Read the server greeting $greeting = $connection->readGreeting(); // $greeting->code: 220 // $greeting->message: "smtp.example.com ESMTP" // EHLO to discover capabilities $ehlo = $connection->ehlo('client.example.com'); // Check capabilities $connection->supportsCapability('PIPELINING'); // bool $connection->supportsCapability('8BITMIME'); // bool $connection->getCapabilityValue('AUTH'); // "PLAIN LOGIN" or null $connection->maxSize; // int or null // Send arbitrary commands $reply = $connection->sendCommand(new Command('NOOP')); // $reply->code: 250 // $reply->isPositiveCompletion(): true // Or send without reading reply (for pipelining) $connection->writeCommand(new Command('NOOP')); $reply = $connection->readReply(); $connection->close(); ``` ## SMTP Extensions The transport automatically negotiates these extensions when the server advertises them: | Extension | RFC | Description | |-----------|-----|-------------| | PIPELINING | RFC 2920 | Send multiple commands before reading replies | | CHUNKING | RFC 3030 | BDAT transfer without dot-stuffing | | BINARYMIME | RFC 3030 | Binary content without transfer encoding | | 8BITMIME | RFC 6152 | 8-bit MIME transport | | SMTPUTF8 | RFC 6531 | Internationalized email addresses | | STARTTLS | RFC 3207 | TLS upgrade on plaintext connections | | DSN | RFC 3461 | Delivery status notifications | | REQUIRETLS | RFC 8689 | End-to-end TLS enforcement | | MT-PRIORITY | RFC 6710 | Message priority levels | | DELIVERBY | RFC 2852 | Delivery deadline specification | | FUTURERELEASE | RFC 4865 | Deferred delivery | | SIZE | RFC 1870 | Server maximum message size | ## Security The transport validates all SMTP commands for CRLF and null byte injection attacks. Malicious input in addresses, hostnames, or DSN parameters throws `PossibleAttackException` before reaching the wire. See [src/Psl/SMTP/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/smtp/src/Psl/SMTP/) for the full API. --- # Ansi The `Ansi` component provides pure functions for constructing ANSI escape sequences -- text styling, colors, cursor movement, screen manipulation, hyperlinks, and terminal mode control. All functions return immutable sequence objects that implement `CommandInterface`. No I/O is performed -- call `toString()` to get the raw escape string for writing to a terminal. ## Usage ```php use Psl\Ansi; use Psl\Ansi\Color; use Psl\Ansi\Cursor; use Psl\Ansi\Screen; use Psl\Ansi\Style; use Psl\IO; // Style and color text IO\write(Ansi\apply('Hello, world!', Style\bold(), Ansi\foreground(Color\green()))); IO\write("\n"); // Cursor movement IO\write(Cursor\move_to(1, 1)->toString()); IO\write(Cursor\hide()->toString()); // Screen control IO\write(Screen\erase(Screen\EraseMode::Full)->toString()); IO\write(Screen\title('My App')->toString()); // Hyperlinks (OSC 8) IO\write(Ansi\link('Click here', 'https://example.com', Style\underline())); IO\write("\n"); // Strip ANSI sequences from text $plain = Ansi\strip("\e[1mBold\e[0m"); // "Bold" IO\write_line('Stripped: %s', $plain); ``` ## Design The component is organized around three sequence types that implement `CommandInterface`: - **`ControlSequenceIntroducer`** (CSI) -- sequences starting with `\e[`, used for cursor movement, text styling (SGR), screen erasing, scrolling, and DEC private mode toggling. - **`OperatingSystemCommand`** (OSC) -- sequences starting with `\e]`, used for setting window titles, hyperlinks, clipboard access, and desktop notifications. - **`ControlCharacter`** -- single-byte control characters (e.g. BEL `\x07`) that don't fit the CSI or OSC format. All are `final readonly` classes with a `toString()` method that produces the raw escape string. Functions in the component return one of these types (or a plain `string` for composed output like `apply()` and `link()`). ## Text Styling The `apply()` function wraps text with SGR (Select Graphic Rendition) sequences and an automatic reset. It accepts any combination of styles and colors: ```php use Psl\Ansi; use Psl\Ansi\Color; use Psl\Ansi\Style; use Psl\IO; // Combine multiple styles IO\write(Ansi\apply('important', Style\bold(), Style\underline(), Ansi\foreground(Color\red()))); IO\write("\n"); // Background colors IO\write(Ansi\apply(' PASS ', Style\bold(), Ansi\foreground(Color\white()), Ansi\background(Color\green()))); IO\write("\n"); ``` Colors are created through factory functions in the `Color` namespace and passed to `foreground()` or `background()`: - **16 named colors** -- `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, and their `bright_*` variants - **ANSI-256** -- `Color\ansi256(int $code)` for the 256-color palette - **24-bit RGB** -- `Color\rgb(int $r, int $g, int $b)` for true color - **Hex** -- `Color\hex(string $hex)` for hex color strings like `'#FF6600'` ## Hyperlinks The `link()` function wraps text in an OSC 8 hyperlink, with optional SGR styles applied to the visible text: ```php use Psl\Ansi; use Psl\Ansi\Style; use Psl\IO; IO\write(Ansi\link('GitHub', 'https://github.com', Style\bold(), Style\underline())); IO\write("\n"); ``` Terminal emulators that support OSC 8 render the text as a clickable link. ## Cursor Control Functions in the `Cursor` namespace manipulate cursor position and visibility: ```php use Psl\Ansi\Cursor; use Psl\IO; IO\write(Cursor\move_to(1, 1)->toString()); // absolute position (1-based row, column) IO\write(Cursor\up(5)->toString()); // relative movement IO\write(Cursor\down(3)->toString()); IO\write(Cursor\forward(10)->toString()); IO\write(Cursor\back(2)->toString()); IO\write(Cursor\save()->toString()); // save position IO\write(Cursor\restore()->toString()); // restore saved position IO\write(Cursor\hide()->toString()); // hide cursor IO\write(Cursor\show()->toString()); // show cursor ``` ## Screen Control Functions in the `Screen` namespace control the display, window properties, and terminal modes. ```php use Psl\Ansi\Screen; use Psl\IO; // Erasing and scrolling IO\write(Screen\erase(Screen\EraseMode::Full)->toString()); // erase entire screen IO\write(Screen\erase_line(Screen\LineEraseMode::Right)->toString()); // erase from cursor to end of line IO\write(Screen\scroll_up(5)->toString()); // scroll viewport up // Window properties IO\write(Screen\title('My App')->toString()); // set window title IO\write(Screen\notify('Build complete')->toString()); // desktop notification // Progress indicator (supported by Windows Terminal, ConEmu, Kitty, Ghostty) IO\write(Screen\progress(Screen\ProgressState::Normal, 50)->toString()); // 50% progress IO\write(Screen\progress(Screen\ProgressState::Indeterminate)->toString()); // animated spinner IO\write(Screen\progress_clear()->toString()); // remove indicator // Terminal modes IO\write(Screen\set_mode(Screen\ScreenMode::AlternateScreen)->toString()); // alternate screen buffer for TUIs IO\write(Screen\set_mode(Screen\ScreenMode::MouseTracking)->toString()); // mouse click and scroll events IO\write(Screen\set_mode(Screen\ScreenMode::BracketedPaste)->toString()); // distinguish pasted text from typed input ``` ## Examples ### Styled Log Output ```php use Psl\Ansi; use Psl\Ansi\Color; use Psl\Ansi\Style; use Psl\IO; IO\write_line(Ansi\apply('Error: something went wrong', Style\bold(), Ansi\foreground(Color\red()))); IO\write_line(Ansi\apply('Warning: check configuration', Ansi\foreground(Color\yellow()))); IO\write_line(Ansi\apply('Success!', Style\bold(), Ansi\foreground(Color\green()))); ``` ### TUI Setup and Teardown ```php use Psl\Ansi; use Psl\Ansi\Cursor; use Psl\Ansi\Screen; use Psl\IO; // Enter TUI mode IO\write( Screen\set_mode(Screen\ScreenMode::AlternateScreen)->toString() . Cursor\hide()->toString() . Screen\erase(Screen\EraseMode::Full)->toString() . Cursor\move_to(1, 1)->toString() . Screen\title('My TUI App')->toString(), ); // ... render UI ... // Exit TUI mode IO\write( Cursor\show()->toString() . Ansi\reset()->toString() . Screen\reset_mode(Screen\ScreenMode::AlternateScreen)->toString() . Screen\title('')->toString(), ); ``` See [src/Psl/Ansi/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/ansi/src/Psl/Ansi/) for the full API. --- # Terminal The `Terminal` component provides a full-featured TUI (Terminal User Interface) framework built on top of the `Ansi` and `Async` components. It handles the event loop, raw mode, input parsing, diff-based rendering, and provides a library of composable widgets for building interactive terminal applications. ## Quick Start ```php use Psl\Terminal; use Psl\Terminal\Event; use Psl\Terminal\Widget; final class MyState {} $app = Terminal\Application::create(new MyState(), title: 'My App'); $app->on(Event\Key::class, static function (Event\Key $event, MyState $_state) use ($app): void { if ($event->is('ctrl+c')) { $app->stop(); } }); return $app->run(static function (Terminal\Frame $frame, MyState $_state): void { $buffer = $frame->buffer(); Widget\Paragraph::new([ Widget\Line::new([Widget\Span::raw('Hello, World!')]), ])->render($frame->rect(), $buffer); }); ``` ## Architecture The framework follows an immediate-mode rendering model: 1. **Application** manages the event loop, terminal setup/teardown, and render scheduling 2. **Events** are parsed from raw stdin bytes and dispatched to registered handlers 3. **Frame** is provided to the render callback each tick with a fresh **Buffer** 4. **Widgets** render themselves into a rectangular **Rect** area of the **Buffer** 5. **Buffer** diffs against the previous frame and writes only changed cells ## Application ### Local Terminal For local terminal applications using STDIN/STDOUT: ```php use Psl\DateTime; use Psl\Terminal; final class AppState {} $app = Terminal\Application::create( state: new AppState(), title: 'My App', tickInterval: DateTime\Duration::milliseconds(16), // ~60 ticks/s (default: 16ms) scrollSmoothing: true, // filter trackpad scroll micro-reversals (default: true) mouseMotion: false, // track mouse movement, not just clicks (default: false) ); ``` This automatically handles raw mode, terminal size detection, SIGWINCH/SIGINT signal handling, and in-band resize notifications. ### Custom I/O For remote scenarios (e.g. SSH servers) or testing, where you provide your own I/O handles: ```php use Psl\IO; use Psl\Terminal; final class RemoteState {} $sshInputStream = IO\input_handle(); $sshOutputStream = IO\output_handle(); $app = Terminal\Application::custom( state: new RemoteState(), input: $sshInputStream, output: $sshOutputStream, width: 80, height: 24, title: 'Remote App', ); ``` In this mode, raw mode is not managed by the application -- the caller is responsible for it. Signal handlers are not registered, and Ctrl+C is handled via the input stream parser. Use `$app->dispatch(new Event\Resize($cols, $rows))` to inject resize events. ## Event Handling Register handlers for specific event types. Multiple handlers per event type are supported: ```php use Psl\Terminal; use Psl\Terminal\Event; use Psl\Terminal\Widget; final class EventState {} $app = Terminal\Application::create(new EventState(), title: 'Events Demo'); $app->on(Event\Key::class, static function (Event\Key $event, EventState $_state) use ($app): void { if ($event->is('ctrl+c')) { $app->stop(); } if ($event->is('enter')) { /** * handle enter */ } if ($event->is('up')) { /** * handle up arrow */ } }); $app->on(Event\Mouse::class, static function (Event\Mouse $_event, EventState $_state): void { // $_event->kind (Press, Release, Drag, ScrollUp, ScrollDown, Move) // $_event->column, $_event->row }); $app->on(Event\Paste::class, static function (Event\Paste $_event, EventState $_state): void { // $_event->text -- the pasted string }); $app->on(Event\Resize::class, static function (Event\Resize $_event, EventState $_state): void { // $_event->width, $_event->height }); $app->run(static function (Terminal\Frame $frame, EventState $_state): void { Widget\Paragraph::new([ Widget\Line::new([Widget\Span::raw('Press Ctrl+C to exit')]), ])->render($frame->rect(), $frame->buffer()); }); ``` Periodic callbacks can be registered for tasks like polling or animation: ```php use Psl\DateTime\Duration; use Psl\Terminal; use Psl\Terminal\Event; use Psl\Terminal\Widget; final class TimerState { public int $ticks = 0; } $app = Terminal\Application::create(new TimerState(), title: 'Interval Demo'); $app->on(Event\Key::class, static function (Event\Key $event, TimerState $_state) use ($app): void { if ($event->is('ctrl+c')) { $app->stop(); } }); $app->interval(Duration::milliseconds(100), static function (TimerState $state): void { // runs every 100ms $state->ticks++; }); $app->run(static function (Terminal\Frame $frame, TimerState $state): void { Widget\Paragraph::new([ Widget\Line::new([Widget\Span::raw('Ticks: ' . $state->ticks)]), ])->render($frame->rect(), $frame->buffer()); }); ``` ## Layout The layout system splits rectangular areas into sub-regions using constraints: ```php use Psl\Terminal; use Psl\Terminal\Event; use Psl\Terminal\Layout; use Psl\Terminal\Widget; final class LayoutState {} $app = Terminal\Application::create(new LayoutState(), title: 'Layout Demo'); $app->on(Event\Key::class, static function (Event\Key $event, LayoutState $_state) use ($app): void { if ($event->is('ctrl+c')) { $app->stop(); } }); $app->run(static function (Terminal\Frame $frame, LayoutState $_state): void { $buffer = $frame->buffer(); // Vertical split: header (3 rows) + content (fill) + footer (1 row) [$header, $content, $footer] = Layout\vertical($frame, [ Layout\fixed(3), Layout\fill(), Layout\fixed(1), ]); // Horizontal split: sidebar (20 cols) + main (fill) [$sidebar, $main] = Layout\horizontal($content, [ Layout\fixed(20), Layout\fill(), ]); // Constraints can be wrapped with min/max // Layout\min(10, Layout\fill()); // fill, but at least 10 // Layout\max(50, Layout\fill()); // fill, but at most 50 Widget\Paragraph::new([ Widget\Line::new([Widget\Span::raw('Header Area')]), ])->render($header, $buffer); Widget\Paragraph::new([ Widget\Line::new([Widget\Span::raw('Sidebar')]), ])->render($sidebar, $buffer); Widget\Paragraph::new([ Widget\Line::new([Widget\Span::raw('Main Content')]), ])->render($main, $buffer); Widget\Paragraph::new([ Widget\Line::new([Widget\Span::raw('Footer')]), ])->render($footer, $buffer); }); ``` ## Widgets All widgets implement `WidgetInterface` with a single method: `render(Rect $area, Buffer $buffer): void`. They use a builder pattern for configuration. ### Paragraph Multi-line text with wrapping, scrolling, and alignment: ```php use Psl\Ansi; use Psl\Ansi\Color; use Psl\Ansi\Style; use Psl\Terminal; use Psl\Terminal\Event; use Psl\Terminal\Widget; final class ParagraphState {} $app = Terminal\Application::create(new ParagraphState(), title: 'Paragraph Demo'); $app->on(Event\Key::class, static function (Event\Key $event, ParagraphState $_state) use ($app): void { if ($event->is('ctrl+c')) { $app->stop(); } }); $app->run(static function (Terminal\Frame $frame, ParagraphState $_state): void { Widget\Paragraph::new([ Widget\Line::new([ Widget\Span::styled('Error: ', Ansi\foreground(Color\red()), Style\bold()), Widget\Span::raw('something went wrong'), ]), Widget\Line::new([Widget\Span::raw('Check the logs for details.')]), ]) ->wrap(Widget\Wrap::Word) ->alignment(Widget\Alignment::Left) ->scroll(0) ->render($frame->rect(), $frame->buffer()); }); ``` ### Block A container that draws a border and optional title around an inner widget: ```php use Psl\Ansi; use Psl\Ansi\Color; use Psl\Ansi\Style; use Psl\Terminal; use Psl\Terminal\Event; use Psl\Terminal\Widget; final class BlockState {} $app = Terminal\Application::create(new BlockState(), title: 'Block Demo'); $app->on(Event\Key::class, static function (Event\Key $event, BlockState $_state) use ($app): void { if ($event->is('ctrl+c')) { $app->stop(); } }); $app->run(static function (Terminal\Frame $frame, BlockState $_state): void { $area = $frame->rect(); $buffer = $frame->buffer(); $block = Widget\Block::new() ->title(' Status ') ->titleStyle(Ansi\foreground(Color\bright_white()), Style\bold()) ->border(Widget\Border::rounded(Ansi\foreground(Color\bright_cyan()))) ->padding(right: 1, left: 1) ->margin(top: 1) ->background(Color\ansi256(235)); $paragraph = Widget\Paragraph::new([ Widget\Line::new([Widget\Span::raw('All systems operational.')]), ]); $block->render($area, $paragraph, $buffer); }); ``` Border styles: `Border::rounded()`, `Border::plain()`, `Border::double()`, `Border::thick()`. ### Table Columnar data with headers, scrolling, and row highlighting: ```php use Psl\Ansi; use Psl\Ansi\Color; use Psl\Terminal; use Psl\Terminal\Event; use Psl\Terminal\Widget; final class TableState {} $app = Terminal\Application::create(new TableState(), title: 'Table Demo'); $app->on(Event\Key::class, static function (Event\Key $event, TableState $_state) use ($app): void { if ($event->is('ctrl+c')) { $app->stop(); } }); $app->run(static function (Terminal\Frame $frame, TableState $_state): void { Widget\Table::new() ->headers(['Name', 'Status', 'CPU']) ->widths([15, 10, 8]) ->rows([ [ Widget\Span::raw('nginx'), Widget\Span::styled('running', Ansi\foreground(Color\green())), Widget\Span::raw('2.1%'), ], [ Widget\Span::raw('postgres'), Widget\Span::styled('running', Ansi\foreground(Color\green())), Widget\Span::raw('5.3%'), ], ]) ->highlight(0) ->highlightStyle(Ansi\foreground(Color\bright_white()), Ansi\background(Color\blue())) ->render($frame->rect(), $frame->buffer()); }); ``` ### Menu A selectable list of items with scrolling and highlight: ```php use Psl\Ansi; use Psl\Ansi\Color; use Psl\Terminal; use Psl\Terminal\Event; use Psl\Terminal\Widget; final class MenuState { public int $selectedIndex = 0; public int $scrollOffset = 0; } $app = Terminal\Application::create(new MenuState(), title: 'Menu Demo'); $app->on(Event\Key::class, static function (Event\Key $event, MenuState $_state) use ($app): void { if ($event->is('ctrl+c')) { $app->stop(); } }); $app->run(static function (Terminal\Frame $frame, MenuState $state): void { Widget\Menu::new([ Widget\MenuItem::raw('Open File'), Widget\MenuItem::raw('Save'), Widget\MenuItem::styled([ Widget\Span::styled('Quit', Ansi\foreground(Color\red())), ]), ]) ->highlight($state->selectedIndex) ->scroll($state->scrollOffset) ->highlightStyle(Ansi\foreground(Color\bright_white()), Ansi\background(Color\blue())) ->render($frame->rect(), $frame->buffer()); }); ``` ### Other Widgets - **Tabs** -- horizontal tab bar with active/inactive styling - **Gauge** -- horizontal progress bar with label and percentage - **Sparkline** -- single-row data visualization using Unicode block characters - **BarChart** -- vertical bar chart with labels - **Scrollbar** -- vertical scrollbar for scrollable content - **TextInput** -- single-line text input with cursor and placeholder ### Text Primitives ```php use Psl\Ansi; use Psl\Ansi\Color; use Psl\Ansi\Style; use Psl\Terminal; use Psl\Terminal\Event; use Psl\Terminal\Widget; final class TextState {} $app = Terminal\Application::create(new TextState(), title: 'Text Primitives Demo'); $app->on(Event\Key::class, static function (Event\Key $event, TextState $_state) use ($app): void { if ($event->is('ctrl+c')) { $app->stop(); } }); $app->run(static function (Terminal\Frame $frame, TextState $_state): void { // Span -- a styled text fragment (the smallest text unit) $plain = Widget\Span::raw('plain text'); $boldRed = Widget\Span::styled('bold red', Ansi\foreground(Color\red()), Style\bold()); // Line -- a horizontal sequence of spans $line = Widget\Line::new([ Widget\Span::styled('[INFO] ', Ansi\foreground(Color\blue())), Widget\Span::raw('Server started on port 8080'), ]); Widget\Paragraph::new([ Widget\Line::new([$plain]), Widget\Line::new([$boldRed]), $line, ])->render($frame->rect(), $frame->buffer()); }); ``` ## Buffer The `Buffer` is a 2D grid of `Cell` objects. Widgets write to it, and `flush()` performs diff-based rendering: ```php use Psl\Ansi; use Psl\Ansi\Color; use Psl\Ansi\Style; use Psl\Terminal; use Psl\Terminal\Event; final class BufferState {} $app = Terminal\Application::create(new BufferState(), title: 'Buffer Demo'); $app->on(Event\Key::class, static function (Event\Key $event, BufferState $_state) use ($app): void { if ($event->is('ctrl+c')) { $app->stop(); } }); $app->run(static function (Terminal\Frame $frame, BufferState $_state): void { $buffer = $frame->buffer(); // Direct cell manipulation $buffer->set(0, 0, new Terminal\Cell('X', [Ansi\foreground(Color\red())])); $buffer->setString(0, 1, 'Hello', [Ansi\foreground(Color\green()), Style\bold()]); // Read cells $cell = $buffer->get(0, 0); $grapheme = $cell?->grapheme; }); ``` See [src/Psl/Terminal/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/terminal/src/Psl/Terminal/) for the full API. --- # Crypto The `Crypto` component provides a comprehensive cryptography toolkit built on libsodium and OpenSSL. It covers symmetric encryption, asymmetric encryption, digital signatures, AEAD, key exchange, and key derivation -- all through a type-safe API with dedicated key types that prevent misuse. ## Symmetric Encryption `Crypto\Symmetric` provides authenticated encryption using XChaCha20-Poly1305. A single shared key encrypts and decrypts. Nonces are managed automatically, so you never have to generate or track them: ```php use Psl\Crypto\Symmetric; use Psl\IO; $key = Symmetric\generate_key(); $ciphertext = Symmetric\seal('Hello, World!', $key); $plaintext = Symmetric\open($ciphertext, $key); IO\write_line('Decrypted: %s', $plaintext); // Decrypted: Hello, World! ``` ### Additional Authenticated Data You can bind extra context to the ciphertext with additional authenticated data (AAD). The AAD is not encrypted, but decryption will fail if it does not match exactly: ```php use Psl\Crypto\Exception; use Psl\Crypto\Symmetric; use Psl\IO; $key = Symmetric\generate_key(); // Additional authenticated data (AAD) is authenticated but not encrypted. // Use it for metadata that must be verified alongside the ciphertext. $ciphertext = Symmetric\seal('secret payload', $key, additionalData: 'user-id:42'); // Decryption succeeds only if the same AAD is provided $plaintext = Symmetric\open($ciphertext, $key, additionalData: 'user-id:42'); IO\write_line('Decrypted: %s', $plaintext); // Wrong or missing AAD causes decryption to fail try { Symmetric\open($ciphertext, $key, additionalData: 'user-id:99'); } catch (Exception\DecryptionException) { IO\write_line('Decryption failed: AAD mismatch.'); } ``` This is useful for tying ciphertext to a specific user, session, or record without including that metadata in the encrypted payload. ### Stream Encryption For large data or file encryption, the `StreamEncryptor` class provides streaming encryption. Data is processed in chunks so memory usage stays constant regardless of input size: ```php use Psl\Crypto\Symmetric; use Psl\IO; $key = Symmetric\generate_key(); $streamEncryptor = new Symmetric\StreamEncryptor($key); // Encrypt a stream in chunks (useful for large files) $source = new IO\MemoryHandle('This is a large message that will be encrypted in chunks.'); $encrypted = new IO\MemoryHandle(); $streamEncryptor->copySealed($source, $encrypted, chunkSize: 4096); // Decrypt the stream back $encrypted->seek(0); $decrypted = new IO\MemoryHandle(); $streamEncryptor->copyOpened($encrypted, $decrypted); $decrypted->seek(0); IO\write_line('Decrypted: %s', $decrypted->readAll()); ``` The stream format uses XChaCha20-Poly1305 secretstream, which authenticates each chunk individually and detects reordering or truncation. ## Asymmetric Encryption `Crypto\Asymmetric` provides public-key encryption using X25519. Each party has a key pair consisting of a secret key and a public key. ### Sealed Boxes (Anonymous Sender) Sealed boxes let you encrypt a message for a recipient using only their public key. The sender remains anonymous -- the recipient cannot determine who sent the message: ```php use Psl\Crypto\Asymmetric; use Psl\IO; $keyPair = Asymmetric\generate_key_pair(); // Sealed boxes: encrypt with just a public key (anonymous sender). // Only the holder of the secret key can decrypt. $ciphertext = Asymmetric\seal('Anonymous tip: the cake is a lie.', $keyPair->publicKey); $plaintext = Asymmetric\open($ciphertext, $keyPair->secretKey, $keyPair->publicKey); IO\write_line('Decrypted: %s', $plaintext); ``` Use sealed boxes when the sender's identity does not need to be verified, such as anonymous feedback or one-way secret submission. ### Authenticated Encryption When both parties have key pairs, `Asymmetric\encrypt()` provides authenticated encryption. The recipient can verify the message came from the expected sender: ```php use Psl\Crypto\Asymmetric; use Psl\IO; // Both parties generate key pairs $alice = Asymmetric\generate_key_pair(); $bob = Asymmetric\generate_key_pair(); // Alice sends an authenticated message to Bob $ciphertext = Asymmetric\encrypt( 'Hello Bob, this is Alice.', senderSecretKey: $alice->secretKey, recipientPublicKey: $bob->publicKey, ); // Bob decrypts and verifies it came from Alice $plaintext = Asymmetric\decrypt($ciphertext, recipientSecretKey: $bob->secretKey, senderPublicKey: $alice->publicKey); IO\write_line('From Alice: %s', $plaintext); ``` A random nonce is generated and prepended to the ciphertext automatically, so each message is unique even if the plaintext is the same. ### Encryptor Class The `Encryptor` class implements `Crypto\EncryptorInterface` for sealed-box encryption, useful for dependency injection: ```php use Psl\Crypto\Asymmetric; use Psl\IO; $keyPair = Asymmetric\generate_key_pair(); // The Encryptor class implements the EncryptorInterface for sealed-box encryption. // Useful when you need an object to pass around or inject as a dependency. $encryptor = new Asymmetric\Encryptor($keyPair->secretKey, $keyPair->publicKey); $ciphertext = $encryptor->seal('Encrypt me'); $plaintext = $encryptor->open($ciphertext); IO\write_line('Decrypted: %s', $plaintext); ``` ## Digital Signatures `Crypto\Signing` provides Ed25519 digital signatures. A signature proves that a message was created by the holder of a specific secret key and has not been modified. Unlike HMAC, the verifier only needs the public key, so the signing key is never shared. ### Signing and Verifying `Signing\sign()` creates a detached signature, and `Signing\verify()` checks it against the original message: ```php use Psl\Crypto\Signing; use Psl\IO; $keyPair = Signing\generate_key_pair(); $message = 'This message is authentic.'; $signature = Signing\sign($message, $keyPair->secretKey); $valid = Signing\verify($signature, $message, $keyPair->publicKey); IO\write_line('Signature valid: %s', $valid ? 'yes' : 'no'); // Signature valid: yes $valid = Signing\verify($signature, 'tampered message', $keyPair->publicKey); IO\write_line('Tampered valid: %s', $valid ? 'yes' : 'no'); // Tampered valid: no ``` If any byte of the message changes, verification fails. The signature does not reveal the message contents. ### Signer and Verifier Classes The `Signer` and `Verifier` classes wrap the signing and verification logic. They are useful for dependency injection, letting you separate the signing side (which holds the secret key) from the verification side (which only needs the public key): ```php use Psl\Crypto\Signing; use Psl\IO; $keyPair = Signing\generate_key_pair(); // The Signer and Verifier classes are useful for dependency injection. $signer = new Signing\Signer($keyPair->secretKey); $verifier = new Signing\Verifier($keyPair->publicKey); $signature = $signer->sign('Important document contents'); $valid = $verifier->verify($signature, 'Important document contents'); IO\write_line('Valid: %s', $valid ? 'yes' : 'no'); ``` ## AEAD Encryption `Crypto\Aead` provides low-level authenticated encryption with associated data. Unlike `Crypto\Symmetric`, you manage the nonce yourself. This gives you full control but requires care: reusing a nonce with the same key is catastrophic for security. > **Use `Crypto\Symmetric` instead** unless you have a specific reason to manage nonces manually. The symmetric API handles nonces automatically and is harder to misuse. ```php use Psl\Crypto\Aead; use Psl\IO; use Psl\SecureRandom; $algorithm = Aead\Algorithm::XChaCha20Poly1305; $key = Aead\generate_key($algorithm); // AEAD requires a unique nonce for each message. // Never reuse a nonce with the same key. $nonce = SecureRandom\bytes(24); $ciphertext = Aead\encrypt( 'Sensitive data', $key, nonce: $nonce, additionalData: 'context-info', algorithm: $algorithm, ); $plaintext = Aead\decrypt($ciphertext, $key, $nonce, 'context-info', $algorithm); IO\write_line('Decrypted: %s', $plaintext); ``` The `Aead\Algorithm` enum supports three constructions: | Algorithm | Nonce Size | Notes | |-----------|-----------|-------| | `XChaCha20Poly1305` | 24 bytes | Recommended. Large nonce makes accidental reuse unlikely. | | `ChaCha20Poly1305` | 8 bytes | IETF standard. Smaller nonce requires careful management. | | `Aes256Gcm` | 12 bytes | Hardware-accelerated on supported CPUs. Not available everywhere. | ## Key Exchange `Crypto\KeyExchange` provides X25519 Diffie-Hellman key agreement. Two parties can each combine their own secret key with the other's public key to arrive at the same shared secret, without ever transmitting the secret itself: ```php use Psl\Crypto\KeyExchange; use Psl\IO; // Both parties generate key pairs $alice = KeyExchange\generate_key_pair(); $bob = KeyExchange\generate_key_pair(); // Each party computes a shared secret using their secret key and the other's public key $aliceShared = KeyExchange\agree($alice->secretKey, $bob->publicKey); $bobShared = KeyExchange\agree($bob->secretKey, $alice->publicKey); // Both arrive at the same shared secret IO\write_line('Secrets match: %s', $aliceShared->bytes === $bobShared->bytes ? 'yes' : 'no'); // Secrets match: yes ``` The shared secret can then be used as input keying material for key derivation to produce session keys. ## Key Derivation PSL provides two key derivation mechanisms: `Crypto\Kdf` for deriving subkeys from a master key, and `Crypto\Hkdf` for deriving keys from arbitrary input keying material using the HMAC-based extract-and-expand pattern (RFC 5869). ### KDF: Subkey Derivation `Kdf\derive()` produces deterministic subkeys from a master key. Each subkey is identified by a numeric ID and an 8-byte context string: ```php use Psl\Crypto\Kdf; use Psl\IO; use Psl\Str\Byte; $masterKey = Kdf\generate_key(); // Derive subkeys for different purposes using a context string (exactly 8 bytes) // and a numeric subkey ID. $encryptionKey = Kdf\derive($masterKey, subKeyId: 1, context: 'encryptn', length: 32); $signingKey = Kdf\derive($masterKey, subKeyId: 2, context: 'encryptn', length: 32); IO\write_line('Encryption key length: %d bytes', Byte\length($encryptionKey)); IO\write_line('Signing key length: %d bytes', Byte\length($signingKey)); IO\write_line('Keys are different: %s', $encryptionKey !== $signingKey ? 'yes' : 'no'); ``` This is useful when you need multiple keys for different purposes (encryption, signing, tokens) but want to store and manage only a single master key. ### HKDF: Extract and Expand `Hkdf\derive()` implements the full HKDF construction from RFC 5869. It takes raw input keying material (such as a shared secret from key exchange) and produces cryptographically strong key material: ```php use Psl\Crypto\Hkdf; use Psl\Hash\Hmac; use Psl\IO; use Psl\Str\Byte; // HKDF (RFC 5869) derives keys from input keying material. // Useful when you have a shared secret from key exchange and need session keys. $inputKeyingMaterial = 'raw-shared-secret-from-key-exchange'; // One-step derivation (extract + expand combined) $derivedKey = Hkdf\derive( inputKeyingMaterial: $inputKeyingMaterial, salt: 'optional-salt', info: 'session-encryption-key', length: 32, algorithm: Hmac\Algorithm::Sha256, ); IO\write_line('Derived key length: %d bytes', Byte\length($derivedKey)); // Two-step derivation for deriving multiple keys from the same PRK $prk = Hkdf\extract($inputKeyingMaterial, salt: 'my-salt'); $key1 = Hkdf\expand($prk, info: 'encryption', length: 32); $key2 = Hkdf\expand($prk, info: 'authentication', length: 32); IO\write_line('Keys are different: %s', $key1 !== $key2 ? 'yes' : 'no'); ``` The two-step API (`Hkdf\extract()` + `Hkdf\expand()`) is useful when you need to derive multiple keys from the same source material, as you only pay the extraction cost once. See [src/Psl/Crypto/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/crypto/src/Psl/Crypto/) for the full API. --- # Hash The `Hash` component provides cryptographic and non-cryptographic hashing through a type-safe API. It wraps PHP's hash functions with an `Algorithm` enum that prevents typos and invalid algorithm names, and includes timing-safe comparison and HMAC support. ## Hashing Data `Hash\hash()` computes a message digest for the given data and algorithm: ```php use Psl\Hash; use Psl\IO; $digest = Hash\hash('Hello, World!', Hash\Algorithm::Sha256); // 'dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f' IO\write_line('SHA-256: %s', $digest); $md5 = Hash\hash('Hello, World!', Hash\Algorithm::Md5); IO\write_line('MD5: %s', $md5); ``` The `Hash\Algorithm` enum covers all algorithms available in PHP, including SHA-1, SHA-256, SHA-512, SHA-3 variants, MD5, CRC32, xxHash, MurmurHash, and many more. ## Comparing Hashes Safely Never compare hashes with `===`. String comparison leaks timing information that can be exploited to guess hashes byte-by-byte. Use `Hash\equals()` instead, which runs in constant time: ```php use Psl\Hash; use Psl\IO; $data = 'sensitive payload'; $expected = Hash\hash($data, Hash\Algorithm::Sha256); $userProvidedHash = $expected; // simulate correct hash // Timing-safe comparison if (Hash\equals($expected, $userProvidedHash)) { IO\write_line('Hashes match.'); } else { IO\write_line('Hashes do not match.'); } ``` ## Incremental Hashing For large data or streaming scenarios, use `Hash\Context` to feed data in chunks: ```php use Psl\Hash; use Psl\IO; $chunkOne = 'Hello, '; $chunkTwo = 'World'; $chunkThree = '!'; $digest = Hash\Context::forAlgorithm(Hash\Algorithm::Sha256) ->update($chunkOne) ->update($chunkTwo) ->update($chunkThree) ->finalize(); IO\write_line('Incremental SHA-256: %s', $digest); ``` The context is immutable -- each `update()` returns a new context, so you can safely branch from any intermediate state. ## HMAC HMAC (Hash-based Message Authentication Code) combines a hash with a secret key to verify both data integrity and authenticity. Use it when you need to ensure data has not been tampered with and was produced by someone who holds the key. ```php use Psl\Hash\Hmac; use Psl\IO; $message = 'Hello, HMAC!'; $secretKey = 'my-secret-key-for-signing'; $signature = Hmac\hash(data: $message, algorithm: Hmac\Algorithm::Sha256, key: $secretKey); IO\write_line('HMAC-SHA256: %s', $signature); ``` A practical example -- signing and verifying an API request: ```php use Psl\Hash; use Psl\Hash\Hmac; use Psl\IO; use Psl\SecureRandom; /** * Simulate an API secret key (in practice, this should be stored securely and not hardcoded) * For demonstration purposes, we generate a random 32-byte string to use as the API secret key. */ $apiSecret = SecureRandom\string(32); // Sender: sign the payload $payload = '{"action":"transfer","amount":100}'; $signature = Hmac\hash($payload, Hmac\Algorithm::Sha256, $apiSecret); IO\write_line('Signature: %s', $signature); // Receiver: verify the signature $receivedSignature = $signature; // simulate receiving the same signature $expected = Hmac\hash($payload, Hmac\Algorithm::Sha256, $apiSecret); if (!Hash\equals($expected, $receivedSignature)) { throw new RuntimeException('Invalid signature -- request may have been tampered with.'); } IO\write_line('Signature verified successfully.'); ``` HMAC also supports incremental hashing through `Hash\Context::hmac()`: ```php use Psl\Hash; use Psl\IO; $key = 'my-hmac-secret-key'; $headerBytes = 'Content-Type: application/json'; $bodyBytes = '{"user":"alice","action":"login"}'; $digest = Hash\Context::hmac(Hash\Hmac\Algorithm::Sha256, $key) ->update($headerBytes) ->update($bodyBytes) ->finalize(); IO\write_line('Incremental HMAC: %s', $digest); ``` See [src/Psl/Hash/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/hash/src/Psl/Hash/) for the full API. --- # Password The `Password` component provides secure password hashing and verification. It wraps PHP's `password_*` functions with a type-safe `Algorithm` enum and a clean functional API, making it easy to hash passwords with bcrypt or Argon2 and handle algorithm upgrades over time. ## Hashing a Password ```php use Psl\IO; use Psl\Password; $plaintext = 'my-secret-password'; // Uses the default algorithm (currently bcrypt, may change in future PHP versions) $hash = Password\hash($plaintext); IO\write_line('Default hash: %s', $hash); // Specify an algorithm explicitly $bcryptHash = Password\hash($plaintext, Password\Algorithm::Bcrypt); IO\write_line('Bcrypt hash: %s', $bcryptHash); ``` You can tune algorithm parameters through the `$options` array: ```php use Psl\IO; use Psl\Password; $plaintext = 'my-secret-password'; // Bcrypt with a higher cost factor (default is 10) $hash = Password\hash($plaintext, Password\Algorithm::Bcrypt, ['cost' => 14]); IO\write_line('Bcrypt (cost 14): %s', $hash); ``` ## Verifying a Password `verify()` checks a plaintext password against a stored hash. The algorithm and salt are embedded in the hash itself, so you do not need to store them separately: ```php use Psl\IO; use Psl\Password; $plaintext = 'my-secret-password'; $storedHash = Password\hash($plaintext, Password\Algorithm::Bcrypt); if (Password\verify($plaintext, $storedHash)) { IO\write_line('Password is correct.'); } else { IO\write_line('Password is incorrect.'); } ``` ## Checking If a Rehash Is Needed When you upgrade your hashing algorithm or increase the cost factor, existing hashes remain valid but are no longer up to your current standard. `needs_rehash()` detects this so you can transparently upgrade hashes at login time: ```php use Psl\IO; use Psl\Password; $plaintext = 'my-secret-password'; $storedHash = Password\hash($plaintext, Password\Algorithm::Bcrypt); if (Password\verify($plaintext, $storedHash)) { IO\write_line('Password is correct.'); // Check if the hash needs upgrading if (Password\needs_rehash($storedHash, Password\Algorithm::Bcrypt, ['cost' => 14])) { $newHash = Password\hash($plaintext, Password\Algorithm::Bcrypt, ['cost' => 14]); IO\write_line('Hash upgraded: %s', $newHash); // store $newHash in the database } else { IO\write_line('Hash is up to date.'); } } ``` This pattern lets you migrate from bcrypt to Argon2id (or increase cost parameters) gradually, without forcing all users to reset their passwords. ## Inspecting a Hash `get_information()` returns the algorithm and options used to create a hash: ```php use Psl\IO; use Psl\Password; $hash = Password\hash('my-secret-password', Password\Algorithm::Bcrypt); $info = Password\get_information($hash); IO\write_line('Algorithm: %s', $info['algorithm']->name); IO\write_line('Cost: %d', $info['options']['cost'] ?? ''); ``` ## Algorithm The `Password\Algorithm` enum provides four cases: - `Default` -- follows PHP's default, which may change over time to adopt stronger algorithms - `Bcrypt` -- Blowfish-based, always produces 60-character hashes - `Argon2i` -- memory-hard algorithm resistant to GPU attacks - `Argon2id` -- hybrid of Argon2i and Argon2d, recommended for most applications When using `Algorithm::Default`, store hashes in a column that can hold at least 255 characters, since the underlying algorithm (and hash length) may change in future PHP versions. See [src/Psl/Password/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/password/src/Psl/Password/) for the full API. --- # PseudoRandom The `PseudoRandom` component provides fast pseudo-random number generation using PHP's Mersenne Twister engine (`mt_rand`). It is appropriate for non-security contexts such as shuffling, sampling, simulations, or any case where speed matters more than cryptographic guarantees. > **Important**: Do not use `PseudoRandom` for tokens, passwords, keys, or anything security-related. Use `SecureRandom` instead. ## Usage ### Random Integers Generate a pseudo-random integer within a range: ```php use Psl\IO; use Psl\PseudoRandom; $roll = PseudoRandom\int(1, 6); IO\write_line('Dice roll: %d', $roll); // Full 64-bit range by default $value = PseudoRandom\int(); IO\write_line('Random value: %d', $value); ``` ### Random Floats Generate a pseudo-random float between 0.0 and 1.0: ```php use Psl\IO; use Psl\PseudoRandom; $chance = PseudoRandom\float(); IO\write_line('Random chance: %f', $chance); if ($chance < 0.3) { IO\write_line('Hit the 30%% probability branch!'); } else { IO\write_line('Missed the 30%% probability branch.'); } ``` ## When to Use PseudoRandom vs SecureRandom | Use case | Component | |---|---| | Dice rolls, shuffling, sampling | `PseudoRandom` | | Simulation / Monte Carlo | `PseudoRandom` | | API tokens, session IDs | `SecureRandom` | | Passwords, encryption keys | `SecureRandom` | | Nonces, CSRF tokens | `SecureRandom` | See [src/Psl/PseudoRandom/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/pseudo-random/src/Psl/PseudoRandom/) for the full API. --- # RandomSequence The `RandomSequence` component provides object-oriented random number sequences behind a common `SequenceInterface`. This is useful when you need to inject a random source as a dependency, swap implementations for testing, or produce reproducible sequences from a known seed. ## The Interface Every sequence implements `SequenceInterface` with a single method: ```php use Psl\IO; use Psl\RandomSequence\SecureSequence; use Psl\RandomSequence\SequenceInterface; /** @var SequenceInterface $sequence */ $sequence = new SecureSequence(); $a = $sequence->next(); // next random int $b = $sequence->next(); // another random int IO\write_line('First: %d', $a); IO\write_line('Second: %d', $b); ``` ## Implementations ### MersenneTwisterSequence A seedable Mersenne Twister (MT19937) PRNG. Given the same seed, it always produces the same sequence, making it ideal for reproducible simulations, procedural generation, and deterministic tests: ```php use Psl\IO; use Psl\RandomSequence\MersenneTwisterSequence; $seq = new MersenneTwisterSequence(seed: 42); $first = $seq->next(); // always the same for seed 42 $second = $seq->next(); // deterministic IO\write_line('First (seed 42): %d', $first); IO\write_line('Second (seed 42): %d', $second); ``` ### MersenneTwisterPHPVariantSequence A variant that matches PHP's internal `mt_rand` twist behavior. Use this when you need compatibility with sequences generated by PHP's built-in Mersenne Twister: ```php use Psl\IO; use Psl\RandomSequence\MersenneTwisterPHPVariantSequence; $seq = new MersenneTwisterPHPVariantSequence(seed: 42); $first = $seq->next(); $second = $seq->next(); IO\write_line('PHP variant first (seed 42): %d', $first); IO\write_line('PHP variant second (seed 42): %d', $second); ``` ### SecureSequence A cryptographically secure sequence backed by `SecureRandom\int()`. Use this in production when security matters: ```php use Psl\IO; use Psl\RandomSequence\SecureSequence; $seq = new SecureSequence(); $value = $seq->next(); // cryptographically secure random int IO\write_line('Secure random: %d', $value); ``` ## Dependency Injection The shared interface makes it easy to swap implementations. Use `MersenneTwisterSequence` in tests for reproducibility and `SecureSequence` in production: ```php use Psl\IO; use Psl\Iter; use Psl\RandomSequence\MersenneTwisterSequence; use Psl\RandomSequence\SecureSequence; use Psl\RandomSequence\SequenceInterface; /** * Picks a random winner from the list of participants using the provided RNG. * * @param non-empty-list $participants * * @return non-empty-string The name of the winner. */ function pick_winner(array $participants, SequenceInterface $rng): string { /** @var int<0, max> $index */ $index = $rng->next() % Iter\count($participants); return $participants[$index]; } // Deterministic for tests $testRng = new MersenneTwisterSequence(seed: 42); $winner = pick_winner(['Alice', 'Bob', 'Charlie'], $testRng); IO\write_line('Test winner: %s', $winner); // Secure for production $prodRng = new SecureSequence(); $winner = pick_winner(['Alice', 'Bob', 'Charlie'], $prodRng); IO\write_line('Production winner: %s', $winner); ``` See [src/Psl/RandomSequence/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/random-sequence/src/Psl/RandomSequence/) for the full API. --- # SecureRandom The `SecureRandom` component provides cryptographically secure random data generation. It wraps PHP's `random_bytes()` and `random_int()` with a consistent API and typed exceptions, making it suitable for security-sensitive tasks like token generation, password creation, and nonce generation. ## Usage ### Random Integers Generate a secure random integer within a range: ```php use Psl\IO; use Psl\SecureRandom; $id = SecureRandom\int(1, 1_000_000); IO\write_line('Random ID: %d', $id); // Full 64-bit range by default $big = SecureRandom\int(); IO\write_line('Random big int: %d', $big); ``` ### Random Floats Generate a secure random float between 0.0 and 1.0: ```php use Psl\IO; use Psl\SecureRandom; $probability = SecureRandom\float(); IO\write_line('Random float: %f', $probability); ``` ### Random Bytes Generate raw random bytes, useful for cryptographic keys or binary tokens: ```php use Psl\IO; use Psl\SecureRandom; $key = SecureRandom\bytes(32); // 32 random bytes for an AES-256 key IO\write_line('Key length: %d bytes', strlen($key)); IO\write_line('Key (hex): %s', bin2hex($key)); $iv = SecureRandom\bytes(16); // 16 random bytes for an IV IO\write_line('IV (hex): %s', bin2hex($iv)); ``` ### Random Strings Generate a random string from an alphabet. Defaults to alphanumeric characters: ```php use Psl\IO; use Psl\SecureRandom; // 32-character alphanumeric token $token = SecureRandom\string(32); IO\write_line('Token: %s', $token); // Hex string $hex = SecureRandom\string(16, '0123456789abcdef'); IO\write_line('Hex: %s', $hex); // Numeric code (e.g. for SMS verification) $code = SecureRandom\string(6, '0123456789'); IO\write_line('Code: %s', $code); ``` ## Error Handling All functions throw `SecureRandom\Exception\InsufficientEntropyException` if the system cannot gather enough entropy, and `SecureRandom\Exception\InvalidArgumentException` for invalid arguments (such as `$min > $max` for `int()`). See [src/Psl/SecureRandom/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/secure-random/src/Psl/SecureRandom/) for the full API. --- # DateTime The `DateTime` component provides immutable, timezone-aware date and time types. It replaces PHP's mutable `DateTime` class with a design where every operation returns a new instance, eliminating an entire class of mutation bugs. The component is built around several core types: `DateTime` for calendar-aware dates, `Timestamp` for raw points in time, `Duration` for exact time spans, `Period` for calendar-based periods, and `Interval` for time ranges. ## Creating Dates ```php use Psl\DateTime; use Psl\IO; $now = DateTime\DateTime::now(); IO\write_line('Now: %s', $now->toRfc3339()); $tokyo = DateTime\DateTime::now(DateTime\Timezone::AsiaTokyo); IO\write_line('Tokyo: %s', $tokyo->toRfc3339()); // From individual components $birthday = DateTime\DateTime::fromParts(DateTime\Timezone::AmericaNewYork, 1990, DateTime\Month::March, 15, 14, 30, 0); IO\write_line('Birthday: %s', $birthday->toRfc3339()); // A specific time today $meeting = DateTime\DateTime::todayAt(9, 0, timezone: DateTime\Timezone::EuropeLondon); IO\write_line('Meeting: %s', $meeting->toRfc3339()); ``` ## Timestamps A `Timestamp` represents a precise point in time as seconds and nanoseconds since the Unix epoch, independent of any timezone: ```php use Psl\DateTime; use Psl\IO; $ts = DateTime\Timestamp::now(); IO\write_line('Seconds since epoch: %d', $ts->getSeconds()); IO\write_line('Nanoseconds: %d', $ts->getNanoseconds()); $specific = DateTime\Timestamp::fromParts(1_700_000_000, 500_000_000); // Create from milliseconds or microseconds $fromMs = DateTime\Timestamp::fromMilliseconds(1_700_000_000_500); $fromUs = DateTime\Timestamp::fromMicroseconds(1_700_000_000_500_000); $dt = DateTime\DateTime::fromTimestamp($ts, DateTime\Timezone::UTC); IO\write_line('From timestamp: %s', $dt->toRfc3339()); ``` Use `Timestamp::monotonic()` for duration measurements -- it uses a monotonic clock unaffected by system time adjustments. ## Duration and Arithmetic `Duration` represents an exact time span (hours, minutes, seconds, nanoseconds). All values are normalized automatically: ```php use Psl\DateTime; use Psl\IO; $twoHours = DateTime\Duration::hours(2); $timeout = DateTime\Duration::seconds(30); $precise = DateTime\Duration::milliseconds(1500); // normalizes to 1s 500ms $now = DateTime\DateTime::now(DateTime\Timezone::UTC); // Add or subtract durations from dates $later = $now->plus(DateTime\Duration::hours(3)); $earlier = $now->minus(DateTime\Duration::hours(7 * 24)); // Convenience methods $tomorrow = $now->plusDays(1); $nextMonth = $now->plusMonths(1); $lastYear = $now->minusYears(1); IO\write_line('Now: %s', $now->toRfc3339()); IO\write_line('3 hours later: %s', $later->toRfc3339()); IO\write_line('7 days earlier: %s', $earlier->toRfc3339()); IO\write_line('Tomorrow: %s', $tomorrow->toRfc3339()); IO\write_line('Next month: %s', $nextMonth->toRfc3339()); IO\write_line('Last year: %s', $lastYear->toRfc3339()); // Measure elapsed time $start = DateTime\Timestamp::now(); $end = DateTime\Timestamp::now(); $elapsed = $end->since($start); // returns Duration IO\write_line('Elapsed: %f seconds', $elapsed->getTotalSeconds()); // ISO 8601 format $duration = DateTime\Duration::fromParts(2, 30, 15); IO\write_line('ISO 8601: %s', $duration->toIso8601()); // PT2H30M15S $parsed = DateTime\Duration::fromIso8601('PT1H45M'); IO\write_line('Parsed: %s', $parsed->toString()); ``` ## Period `Period` represents a calendar-based period (years, months, days). Unlike `Duration`, a period's actual time length depends on context - a month can be 28-31 days, and a day can be 23-25 hours due to DST. Months automatically normalize into years, but days are never normalized: ```php use Psl\DateTime; use Psl\IO; // Create periods from calendar units $oneYear = DateTime\Period::years(1); $sixMonths = DateTime\Period::months(6); $twoWeeks = DateTime\Period::weeks(2); // 14 days $tenDays = DateTime\Period::days(10); // Months automatically normalize into years $period = DateTime\Period::months(14); // 1 year, 2 months IO\write_line('14 months = %s', $period->toString()); // Days are never normalized (a day is not a fixed number of hours) $longPeriod = DateTime\Period::days(365); IO\write_line('365 days = %s', $longPeriod->toString()); // Combine parts $mixed = DateTime\Period::fromParts(1, 6, 15); IO\write_line('Mixed: %s', $mixed->toString()); // Arithmetic $a = DateTime\Period::fromParts(1, 6, 0); $b = DateTime\Period::fromParts(0, 8, 15); IO\write_line('Sum: %s', $a->plus($b)->toString()); IO\write_line('Difference: %s', $a->minus($b)->toString()); IO\write_line('Inverted: %s', $a->invert()->toString()); // Calculate the period between two dates $start = DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2024, 3, 15); $end = DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 7, 20); $between = DateTime\Period::between($start, $end); IO\write_line('Between: %s', $between->toString()); // 1 year(s), 4 month(s), 5 day(s) // ISO 8601 format IO\write_line('ISO 8601: %s', $mixed->toIso8601()); // P1Y6M15D $parsed = DateTime\Period::fromIso8601('P2Y3M10D'); IO\write_line('Parsed: %s', $parsed->toString()); ``` ## Interval `Interval` represents a range between two points in time. It can check containment, overlap, and compute the exact duration between its endpoints: ```php use Psl\DateTime; use Psl\IO; // An Interval is a range between two points in time $start = DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 1, 1); $end = DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 12, 31, 23, 59, 59); $interval = DateTime\Interval::between($start, $end); IO\write_line('Interval: %s', $interval->toString()); // Create from a start point and a duration $hourInterval = DateTime\Interval::from(DateTime\Timestamp::now(), DateTime\Duration::hours(1)); IO\write_line('One hour from now: %s', $hourInterval->toString()); // Get the exact time duration between start and end $duration = $interval->getDuration(); IO\write_line('Duration: %s', $duration->toString()); // Check if a point falls within the interval $mid = DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 6, 15); $outside = DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2026, 1, 1); IO\write_line('Contains mid-year: %s', $interval->contains($mid) ? 'yes' : 'no'); IO\write_line('Contains next year: %s', $interval->contains($outside) ? 'yes' : 'no'); // Check if two intervals overlap and get their intersection $q1 = DateTime\Interval::between( DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 1, 1), DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 3, 31), ); $q2 = DateTime\Interval::between( DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 3, 1), DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 6, 30), ); IO\write_line('Q1 overlaps Q2: %s', $q1->overlaps($q2) ? 'yes' : 'no'); $intersection = $q1->intersection($q2); if (null !== $intersection) { IO\write_line('Intersection: %s', $intersection->toString()); } // Check if one interval fully contains another IO\write_line('Full year contains Q1: %s', $interval->containsInterval($q1) ? 'yes' : 'no'); IO\write_line('Q1 contains full year: %s', $q1->containsInterval($interval) ? 'yes' : 'no'); // Merge overlapping intervals $merged = $q1->merge($q2); IO\write_line('Merged Q1+Q2: %s', $merged->toString()); // Find the gap between non-overlapping intervals $spring = DateTime\Interval::between( DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 3, 1), DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 5, 31), ); $autumn = DateTime\Interval::between( DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 9, 1), DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 11, 30), ); $gap = $spring->gap($autumn); if (null !== $gap) { IO\write_line('Gap between spring and autumn: %s', $gap->toString()); } // ISO 8601 format IO\write_line('ISO 8601: %s', $interval->toIso8601()); $parsed = DateTime\Interval::fromIso8601('2025-01-01T00:00:00Z/2025-12-31T23:59:59Z'); IO\write_line('Parsed: %s', $parsed->toString()); ``` ## Convenience Methods `DateTime` provides convenience methods for common boundary operations: ```php use Psl\DateTime; use Psl\IO; $dt = DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, 6, 15, 14, 30, 45); // Start and end of day $startOfDay = $dt->atStartOfDay(); IO\write_line('Start of day: %s', $startOfDay->toRfc3339()); // 2025-06-15T00:00:00+00:00 $endOfDay = $dt->atEndOfDay(); IO\write_line('End of day: %s', $endOfDay->toRfc3339()); // 2025-06-15T23:59:59.999999999+00:00 // Start and end of month $startOfMonth = $dt->atStartOfMonth(); IO\write_line('Start of month: %s', $startOfMonth->toRfc3339()); // 2025-06-01T00:00:00+00:00 $endOfMonth = $dt->atEndOfMonth(); IO\write_line('End of month: %s', $endOfMonth->toRfc3339()); // 2025-06-30T23:59:59.999999999+00:00 // Start and end of year $startOfYear = $dt->atStartOfYear(); IO\write_line('Start of year: %s', $startOfYear->toRfc3339()); // 2025-01-01T00:00:00+00:00 $endOfYear = $dt->atEndOfYear(); IO\write_line('End of year: %s', $endOfYear->toRfc3339()); // 2025-12-31T23:59:59.999999999+00:00 // Start and end of week (Monday-Sunday) $startOfWeek = $dt->atStartOfWeek(); IO\write_line('Start of week: %s', $startOfWeek->toRfc3339()); // 2025-06-09T00:00:00+00:00 $endOfWeek = $dt->atEndOfWeek(); IO\write_line('End of week: %s', $endOfWeek->toRfc3339()); // 2025-06-15T23:59:59.999999999+00:00 // Useful for date range queries $feb = DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2024, 2, 15); IO\write_line('Feb 2024 ends: day %d', $feb->atEndOfMonth()->getDay()); // 29 (leap year) ``` ## Comparing and Inspecting ```php use Psl\DateTime; use Psl\IO; $a = DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, DateTime\Month::January, 1); $b = DateTime\DateTime::fromParts(DateTime\Timezone::UTC, 2025, DateTime\Month::June, 15); IO\write_line('a before b: %s', $a->before($b) ? 'yes' : 'no'); // true IO\write_line('b after a: %s', $b->after($a) ? 'yes' : 'no'); // true IO\write_line('a same time as a: %s', $a->atTheSameTime($a) ? 'yes' : 'no'); // true IO\write_line('a between a and b: %s', $a->betweenTimeInclusive($a, $b) ? 'yes' : 'no'); // true // Inspect components IO\write_line('Year: %d', $a->getYear()); IO\write_line('Month: %s', $a->getMonthEnum()->name); IO\write_line('Weekday: %s', $a->getWeekday()->name); IO\write_line('Leap year: %s', $a->isLeapYear() ? 'yes' : 'no'); IO\write_line('DST: %s', $a->isDaylightSavingTime() ? 'yes' : 'no'); ``` ## Formatting and Parsing ```php use Psl\DateTime; use Psl\IO; $dt = DateTime\DateTime::now(DateTime\Timezone::UTC); // ICU pattern IO\write_line('ICU: %s', $dt->format('yyyy-MM-dd HH:mm:ss')); // Predefined patterns IO\write_line('ISO 8601: %s', $dt->format(DateTime\FormatPattern::Iso8601)); IO\write_line('SQL: %s', $dt->format(DateTime\FormatPattern::SqlDateTime)); // Style-based (locale-aware) IO\write_line('Long/Short: %s', $dt->toString(DateTime\DateStyle::Long, DateTime\TimeStyle::Short)); // RFC 3339 for APIs IO\write_line('RFC 3339: %s', $dt->toRfc3339()); // Parsing $parsed = DateTime\DateTime::parse('2025-03-15 12:00:00', 'yyyy-MM-dd HH:mm:ss', DateTime\Timezone::UTC); IO\write_line('Parsed: %s', $parsed->toRfc3339()); ``` ## Timezone Conversion ```php use Psl\DateTime; use Psl\IO; $utc = DateTime\DateTime::now(DateTime\Timezone::UTC); $la = $utc->convertToTimezone(DateTime\Timezone::AmericaLosAngeles); // Same moment, different local time IO\write_line('UTC: %s', $utc->toRfc3339()); IO\write_line('LA: %s', $la->toRfc3339()); IO\write_line('Same time: %s', $utc->atTheSameTime($la) ? 'yes' : 'no'); ``` ## Interoperability `DateTime` converts to and from PHP's `DateTimeImmutable` and ICU's `IntlCalendar`: ```php use Psl\DateTime; use Psl\IO; $native = new DateTimeImmutable('2025-03-15 12:00:00', new DateTimeZone('UTC')); $dt = DateTime\DateTime::fromStdlib($native); $back = $dt->toStdlib(); // DateTimeImmutable IO\write_line('From stdlib: %s', $dt->toRfc3339()); IO\write_line('Back to stdlib: %s', $back->format('Y-m-d H:i:s T')); ``` See [src/Psl/DateTime/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/date-time/src/Psl/DateTime/) for the full API. --- # Env The `Env` component provides functions for inspecting and modifying the process environment. It covers environment variables, the current working directory, temporary paths, command-line arguments, and `PATH` manipulation. ## Usage ### Environment Variables Read, write, and remove environment variables for the current process: ```php use Psl\Env; use Psl\IO; // Read -- returns null if not set $home = Env\get_var('HOME'); IO\write_line('HOME: %s', $home ?? '(not set)'); // Write Env\set_var('APP_ENV', 'production'); IO\write_line('APP_ENV: %s', Env\get_var('APP_ENV') ?? '(not set)'); // Remove Env\remove_var('APP_ENV'); IO\write_line('APP_ENV after remove: %s', Env\get_var('APP_ENV') ?? '(not set)'); // Get all environment variables as an associative array $allVars = Env\get_vars(); IO\write_line('Total env vars: %d', count($allVars)); ``` Keys containing `=` or the NUL character are rejected with an `InvariantViolationException`. ### Working Directory ```php use Psl\Env; use Psl\IO; $cwd = Env\current_dir(); IO\write_line('Current directory: %s', $cwd); ``` ### System Paths ```php use Psl\Env; use Psl\IO; $tmp = Env\temp_dir(); IO\write_line('Temp directory: %s', $tmp); ``` ### Command-Line Arguments and Executable Path ```php use Psl\Env; use Psl\IO; $args = Env\args(); IO\write_line('Arguments: %s', implode(', ', $args)); $binary = Env\current_exec(); IO\write_line('Executable: %s', $binary); ``` ### PATH Manipulation Split and join PATH-style strings using the platform's path separator (`:` on Unix, `;` on Windows): ```php use Psl\Env; use Psl\IO; $dirs = Env\split_paths('/usr/local/bin:/usr/bin:/bin'); IO\write_line('Split paths: %s', implode(', ', $dirs)); $path = Env\join_paths('/usr/local/bin', '/usr/bin', '/bin'); IO\write_line('Joined path: %s', $path); ``` See [src/Psl/Env/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/env/src/Psl/Env/) for the full API. --- # Locale The `Locale` component provides a backed enum containing 700+ locale identifiers (such as `en_US`, `fr_FR`, `ja_JP`). Using an enum instead of raw strings eliminates typos, enables IDE autocompletion, and makes locale parameters type-safe throughout your application. ## Usage ### Selecting a Locale Pick a locale by name -- every case maps to its standard locale string: ```php use Psl\IO; use Psl\Locale\Locale; $locale = Locale::EnglishUnitedStates; // 'en_US' IO\write_line('en_US: %s', $locale->value); $locale = Locale::FrenchFrance; // 'fr_FR' IO\write_line('fr_FR: %s', $locale->value); $locale = Locale::Japanese; // 'ja' IO\write_line('ja: %s', $locale->value); $locale = Locale::ChineseSimplified; // 'zh_Hans' IO\write_line('zh_Hans: %s', $locale->value); ``` ### Default Locale `Locale::default()` reads the locale configured via PHP's `intl.default_locale` setting. If no locale is configured or the configured value is not recognized, it falls back to `Locale::English`: ```php use Psl\IO; use Psl\Locale\Locale; $locale = Locale::default(); IO\write_line('Default locale: %s', $locale->value); ``` ### Inspecting a Locale Each locale instance exposes methods to extract its parts: ```php use Psl\IO; use Psl\Locale\Locale; $locale = Locale::FrenchCanada; IO\write_line('Value: %s', $locale->value); // 'fr_CA' IO\write_line('Language: %s', $locale->getLanguage()); // 'fr' IO\write_line('Region: %s', $locale->getRegion() ?? '(none)'); // 'CA' IO\write_line('Display name: %s', $locale->getDisplayName()); // 'French (Canada)' IO\write_line('Display language: %s', $locale->getDisplayLanguage()); // 'French' IO\write_line('Display region: %s', $locale->getDisplayRegion() ?? '(none)'); // 'Canada' IO\write_line('Has region: %s', $locale->hasRegion() ? 'yes' : 'no'); // true IO\write_line('Has script: %s', $locale->hasScript() ? 'yes' : 'no'); // false ``` Script-based locales also work: ```php use Psl\IO; use Psl\Locale\Locale; $locale = Locale::SerbianLatin; IO\write_line('Script: %s', $locale->getScript() ?? '(none)'); // 'Latn' IO\write_line('Has script: %s', $locale->hasScript() ? 'yes' : 'no'); // true ``` ### Integration with DateTime The `Locale` enum is used by PSL's `DateTime` formatting to produce locale-aware output: ```php use Psl\DateTime; use Psl\IO; use Psl\Locale\Locale; $now = DateTime\DateTime::now(); $formatted = $now->format('EEEE, MMMM d', locale: Locale::GermanGermany); IO\write_line('German date: %s', $formatted); ``` See [src/Psl/Locale/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/locale/src/Psl/Locale/) for the full API. --- # OS The `OS` component provides type-safe operating system detection. Instead of comparing raw strings against `PHP_OS_FAMILY`, you work with the `OperatingSystemFamily` enum and convenience functions. ## Usage ### Detecting the OS Family ```php use Psl\IO; use Psl\OS; $family = OS\family(); IO\write_line('OS Family: %s', $family->name); ``` ### Convenience Checks Quick boolean checks for the most common platforms: ```php use Psl\IO; use Psl\OS; if (OS\is_windows()) { IO\write_line('Running on Windows.'); } if (OS\is_darwin()) { IO\write_line('Running on macOS.'); } if (!OS\is_windows() && !OS\is_darwin()) { IO\write_line('Running on Linux or another platform.'); } ``` ### The OperatingSystemFamily Enum The enum covers six families: `Windows`, `BSD`, `Darwin`, `Solaris`, `Linux`, and `Unknown`. You can match against it for exhaustive platform handling: ```php use Psl\IO; use Psl\OS\OperatingSystemFamily; $result = match (OperatingSystemFamily::default()) { OperatingSystemFamily::Windows => 'Configured for Windows', OperatingSystemFamily::Darwin => 'Configured for macOS', OperatingSystemFamily::Linux => 'Configured for Linux', OperatingSystemFamily::BSD => 'Configured for BSD', default => 'Using default configuration', }; IO\write_line('%s', $result); ``` `OperatingSystemFamily::default()` returns the family for the current runtime, making it useful as a dependency injection default. See [src/Psl/OS/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/os/src/Psl/OS/) for the full API. --- # Runtime The `Runtime` component provides introspection into the PHP runtime environment. It exposes version information, loaded extensions, the active SAPI, and build flags through simple function calls. ## Usage ### PHP Version ```php use Psl\IO; use Psl\Runtime; $version = Runtime\get_version(); IO\write_line('PHP version: %s', $version); $id = Runtime\get_version_id(); IO\write_line('Version ID: %d', $id); $details = Runtime\get_version_details(); IO\write_line('Major: %d, Minor: %d, Release: %d', $details['major'], $details['minor'], $details['release']); ``` ### Server API (SAPI) Determine how PHP is being invoked: ```php use Psl\IO; use Psl\Runtime; $sapi = Runtime\get_sapi(); IO\write_line('SAPI: %s', $sapi); ``` ### Extensions Check for loaded extensions or list them all: ```php use Psl\IO; use Psl\Runtime; if (Runtime\has_extension('mbstring')) { IO\write_line('mbstring is available.'); } $extensions = Runtime\get_extensions(); IO\write_line('Loaded extensions: %d', count($extensions)); $zendExtensions = Runtime\get_zend_extensions(); IO\write_line('Zend extensions: %d', count($zendExtensions)); ``` ### Zend Engine ```php use Psl\IO; use Psl\Runtime; $zendVersion = Runtime\get_zend_version(); IO\write_line('Zend Engine version: %s', $zendVersion); ``` ### Build Flags ```php use Psl\IO; use Psl\Runtime; IO\write_line('Debug build: %s', Runtime\is_debug() ? 'yes' : 'no'); IO\write_line('Thread safe: %s', Runtime\is_thread_safe() ? 'yes' : 'no'); ``` See [src/Psl/Runtime/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/runtime/src/Psl/Runtime/) for the full API. --- # Cache The `Cache` component provides an async-safe in-memory LRU cache with per-key atomicity. It prevents cache stampedes in concurrent fiber environments - if two fibers request the same key simultaneously, only one computes the value while the other waits. ## Basic Usage `compute()` is the primary API - get a cached value or compute it if absent. `update()` always recomputes, receiving the old value. ```php use Psl\Cache; use Psl\DateTime\Duration; use Psl\IO; $store = new Cache\LocalStore(maxSize: 100); // compute() returns cached value or computes it $value = $store->compute('greeting', static fn(): string => 'Hello, World!'); IO\write_line('%s', $value); // Second call returns cached value - computer is not invoked $value = $store->compute('greeting', static fn(): string => 'This is never called'); IO\write_line('%s', $value); // Still "Hello, World!" // With TTL - entry expires after 5 minutes $store->compute( 'user:42', /** @return array{name: string} */ static fn(): array => ['name' => 'Alice'], Duration::minutes(5), ); // update() always invokes the computer with the old value $store->compute('counter', static fn(): int => 0); $store->update('counter', static fn(null|int $old): int => ($old ?? 0) + 1); /** @var int $counter */ $counter = $store->get('counter'); IO\write_line('Counter: %d', $counter); ``` ## Async Safety Operations on the same key are serialized via `KeyedSequence`. Different keys run in parallel with no blocking. ```php use Psl\Async; use Psl\Cache; use Psl\DateTime\Duration; use Psl\IO; $store = new Cache\LocalStore(); // Two fibers requesting the same key concurrently. // KeyedSequence ensures only one computes - the other waits and gets the cached result. [$a, $b] = Async\concurrently([ static fn(): string => $store->compute('expensive', static function (): string { Async\sleep(Duration::milliseconds(100)); return 'computed once'; }), static fn(): string => $store->compute('expensive', static fn(): string => 'this is never called'), ]); IO\write_line('Fiber A: %s', $a); // "computed once" IO\write_line('Fiber B: %s', $b); // "computed once" - got cached result // Different keys run in parallel - no blocking Async\concurrently([ static fn(): string => $store->compute('key-1', static fn(): string => 'value-1'), static fn(): string => $store->compute('key-2', static fn(): string => 'value-2'), ]); ``` ## LRU Eviction `LocalStore` maintains a bounded cache with configurable maximum size. When full, the least recently used entry is evicted. Accessed entries are promoted to most-recently-used. ## TTL Expiration Entries with a TTL are proactively cleaned up via an event loop timer, not lazily on access. The timer activates when TTL'd entries exist and disables itself when none remain, adding zero overhead when no entries have expiration. ## NullStore `NullStore` implements `StoreInterface` but never caches - every `compute()` invokes the computer, every `get()` throws. Useful for testing or disabling caching without changing calling code. See [src/Psl/Cache/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/cache/src/Psl/Cache/) for the full API. --- # Class The `Class` component provides type-safe wrappers around PHP's built-in class reflection and existence checks. It replaces loose `class_exists()` calls with a consistent, intention-revealing API and adds helpers for inspecting class properties. ## Why Use This? PHP's native `class_exists()` accepts a second argument to control autoloading, which is easy to forget or misuse. PSL separates that choice into two distinct functions -- `exists()` (triggers autoloading) and `defined()` (checks only already-loaded definitions) -- so the intent is always clear. ## Usage ### Checking Existence ```php use Psl\Class; use Psl\IO; // Triggers autoloading if needed IO\write_line('stdClass exists: %s', Class\exists(stdClass::class) ? 'yes' : 'no'); // Only checks already-loaded classes (no autoloading) IO\write_line('stdClass defined: %s', Class\defined(stdClass::class) ? 'yes' : 'no'); ``` ### Inspecting Classes ```php use Psl\Class; use Psl\IO; IO\write_line('stdClass is final: %s', Class\is_final(stdClass::class) ? 'yes' : 'no'); IO\write_line('stdClass is abstract: %s', Class\is_abstract(stdClass::class) ? 'yes' : 'no'); IO\write_line('stdClass is readonly: %s', Class\is_readonly(stdClass::class) ? 'yes' : 'no'); ``` ### Practical Example Guard a factory method against invalid input: ```php use Psl\Class; use Psl\IO; function create(string $class): object { if (!Class\exists($class)) { throw new InvalidArgumentException($class . ' does not exist.'); } if (Class\is_abstract($class)) { throw new InvalidArgumentException($class . ' is abstract and cannot be instantiated.'); } // @mago-expect analysis:unknown-class-instantiation return new $class(); } $obj = create(stdClass::class); IO\write_line('Created: %s', get_class($obj)); ``` See [src/Psl/Class/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/class/src/Psl/Class/) for the full API. --- # Interface The `Interface` component provides type-safe wrappers around PHP's built-in interface reflection and existence checks. It replaces loose `interface_exists()` calls with a consistent, intention-revealing API. ## Usage PSL separates existence checks into two distinct functions -- `exists()` (triggers autoloading) and `defined()` (checks only already-loaded definitions) -- so the intent is always clear. ```php use Psl\Interface; use Psl\IO; use Psl\Trait; IO\write_line('JsonSerializable exists: %s', Interface\exists(JsonSerializable::class) ? 'yes' : 'no'); IO\write_line('JsonSerializable defined: %s', Interface\defined(JsonSerializable::class) ? 'yes' : 'no'); // Check a non-existent trait IO\write_line('NonExistentTrait exists: %s', Trait\exists('NonExistentTrait') ? 'yes' : 'no'); ``` See [src/Psl/Interface/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/interface/src/Psl/Interface/) for the full API. --- # Interoperability The `Interoperability` component provides a set of interfaces for converting between PSL types and their PHP standard library (`stdlib`) or `intl` extension equivalents. These interfaces establish a uniform contract for bidirectional conversion, allowing PSL types to interoperate seamlessly with native PHP types. ## Usage ```php use Psl\DateTime\DateTime; use Psl\DateTime\Timezone; use Psl\IO; // Convert a PSL DateTime to a PHP DateTimeImmutable $psl = DateTime::fromParts(Timezone::AmericaNewYork, 2024, 6, 15, 14, 30, 45); $stdlib = $psl->toStdlib(); IO\write_line('PSL to stdlib: %s', $stdlib->format('Y-m-d H:i:s T')); // Convert back from a PHP DateTimeImmutable to a PSL DateTime $back = DateTime::fromStdlib($stdlib); IO\write_line('Back to PSL: %s', $back->toRfc3339()); // Convert a PSL Timezone to an IntlTimeZone $intl = Timezone::AmericaNewYork->toIntl(); $id = $intl->getID(); IO\write_line('IntlTimeZone ID: %s', false === $id ? '' : $id); // Convert back from an IntlTimeZone to a PSL Timezone $tz = Timezone::fromIntl($intl); IO\write_line('Back to PSL Timezone: %s', $tz->value); ``` ## Conversion Interfaces The component defines four interfaces covering all conversion directions: - **`ToStdlib`** -- convert a PSL type to its PHP standard library equivalent (e.g. PSL `DateTime` to `DateTimeImmutable`) - **`FromStdlib`** -- create a PSL type from its PHP standard library equivalent (e.g. `DateTimeImmutable` to PSL `DateTime`) - **`ToIntl`** -- convert a PSL type to its `intl` extension equivalent (e.g. PSL `Timezone` to `IntlTimeZone`) - **`FromIntl`** -- create a PSL type from its `intl` extension equivalent (e.g. `IntlTimeZone` to PSL `Timezone`) ## Implementations The following PSL types implement these interfaces: | PSL Type | ToStdlib | FromStdlib | ToIntl | FromIntl | |---|---|---|---|---| | `DateTime\DateTime` | `DateTimeImmutable` | `DateTimeImmutable` | `IntlCalendar` | `IntlCalendar` | | `DateTime\Timestamp` | `DateTimeImmutable` | `DateTimeImmutable` | - | - | | `DateTime\Duration` | `DateInterval` | - | - | - | | `DateTime\Timezone` | `DateTimeZone` | `DateTimeZone` | `IntlTimeZone` | `IntlTimeZone` | See [src/Psl/Interoperability/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/interoperability/src/Psl/Interoperability/) for the full API. --- # Observer The `Observer` component provides two interfaces for the classic Observer design pattern. A subject maintains a list of observers and notifies them when something changes. PSL's interfaces are generic, so observers receive the concrete subject type rather than a vague base class. ## Design - **`SubjectInterface`** -- The object being watched. It can `subscribe()`, `unsubscribe()`, and `notify()` observers. - **`ObserverInterface`** -- The watcher. Its `update()` method receives the subject that triggered the notification, fully typed as `T`. ## Usage The following example demonstrates a complete Subject/Observer implementation, including wiring them together: ```php use Psl\IO; use Psl\Observer; use Psl\Vec; final class Inventory implements Observer\SubjectInterface { /** @var list> */ private array $observers = []; private int $stock = 0; public function subscribe(Observer\ObserverInterface $observer): void { $this->observers[] = $observer; } public function unsubscribe(Observer\ObserverInterface $observer): void { $this->observers = Vec\filter($this->observers, static fn($o) => $o !== $observer); } public function notify(): void { foreach ($this->observers as $observer) { $observer->update($this); } } public function restock(int $quantity): void { $this->stock += $quantity; $this->notify(); } public function getStock(): int { return $this->stock; } } /** @implements Observer\ObserverInterface */ final class StockAlert implements Observer\ObserverInterface { /** @param Inventory $subject */ public function update(Observer\SubjectInterface $subject): void { if ($subject->getStock() > 100) { IO\write_line('Alert: stock is now %d (above threshold)', $subject->getStock()); } } } // Wiring it together $inventory = new Inventory(); $alert = new StockAlert(); $inventory->subscribe($alert); $inventory->restock(150); // StockAlert::update() is called $inventory->unsubscribe($alert); $inventory->restock(50); // No alert -- observer was removed IO\write_line('Final stock: %d', $inventory->getStock()); ``` See [src/Psl/Observer/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/observer/src/Psl/Observer/) for the full API. --- # Trait The `Trait` component provides type-safe wrappers around PHP's built-in trait reflection and existence checks. It replaces loose `trait_exists()` calls with a consistent, intention-revealing API. ## Usage PSL separates existence checks into two distinct functions -- `exists()` (triggers autoloading) and `defined()` (checks only already-loaded definitions) -- so the intent is always clear. ```php use Psl\Interface; use Psl\IO; use Psl\Trait; IO\write_line('JsonSerializable exists: %s', Interface\exists(JsonSerializable::class) ? 'yes' : 'no'); IO\write_line('JsonSerializable defined: %s', Interface\defined(JsonSerializable::class) ? 'yes' : 'no'); // Check a non-existent trait IO\write_line('NonExistentTrait exists: %s', Trait\exists('NonExistentTrait') ? 'yes' : 'no'); ``` See [src/Psl/Trait/](https://github.com/php-standard-library/php-standard-library/tree/6.3.0/packages/trait/src/Psl/Trait/) for the full API. --- # Detect Packages The `detect-packages` command scans PHP source directories for PSL namespace usage and reports which `php-standard-library/*` standalone packages are needed. This helps migrate from the monorepo to individual packages. This tool is only available when using the monorepo (`php-standard-library/php-standard-library`). ## Usage ```bash vendor/bin/psl detect-packages ... [--dev ...] [--composer] ``` ### Options | Option | Description | |--------|-------------| | `--src` | Mark following paths as `require` dependencies (default) | | `--dev` | Mark following paths as `require-dev` dependencies | | `--composer` | Output a JSON snippet for `composer.json` | ## Examples ### Basic scan Scan a single source directory: ```bash vendor/bin/psl detect-packages src/ ``` ``` Detected PSL packages: require: php-standard-library/dict php-standard-library/foundation php-standard-library/str php-standard-library/vec ``` ### Separating require and require-dev Scan source and test directories separately. Packages found in both are placed in `require` only: ```bash vendor/bin/psl detect-packages src/ --dev tests/ ``` ``` Detected PSL packages: require: php-standard-library/dict php-standard-library/foundation php-standard-library/str php-standard-library/vec require-dev: php-standard-library/filesystem php-standard-library/os ``` ### JSON output for composer.json Use `--composer` to get output you can paste directly into your `composer.json`: ```bash vendor/bin/psl detect-packages src/ --dev tests/ --composer ``` ```json { "require": { "php-standard-library/dict": "^6.1", "php-standard-library/str": "^6.1", "php-standard-library/vec": "^6.1" }, "require-dev": { "php-standard-library/filesystem": "^6.1", "php-standard-library/os": "^6.1" } } ``` ### Multiple source paths You can pass multiple directories. Bare paths default to `require`: ```bash vendor/bin/psl detect-packages src/ lib/ --dev tests/ ``` ## How it works The tool scans all `.php` files in the given directories and matches `Psl\` references using regex. Each namespace is mapped to its corresponding standalone package. The `foundation` package is always included when any PSL usage is detected, since all PSL packages depend on it. Transitive dependencies are not resolved — only direct usage in your code is reported. Composer handles transitive dependencies automatically when you install the standalone packages.