azjezz/psl
PSL is a PHP standard library inspired by hhvm/hsl, offering consistent, well-typed, async-ready APIs for collections, networking, I/O, cryptography, terminal UI, and type-safe validation—replacing fragile PHP primitives with predictable alternatives. Requires PHP 8.4+.
Installation:
composer require php-standard-library/php-standard-library:^6.3
Requires PHP 8.4+. 6.3.0 is a minor release — no breaking changes, safe bump from 6.2.x.
First Use Case: Validate and normalise untrusted input with Type combinators:
use Psl\Type;
$orderSchema = Type\shape([
'sku' => Type\non_empty_string(),
'amount' => Type\numeric(), // new in 6.3.0
'qty' => Type\positive_int(),
]);
$order = $orderSchema->coerce($request->all()); // throws on failure
Where to Look First:
Psl\Type — DTO hydration, request payloads, config parsing.Psl\Vec, Psl\Dict, Psl\Iter — allocation-friendly array pipelines.Psl\Str, Psl\Filesystem, Psl\IO — thin, predictable helpers.Psl\Async — genuinely useful, but only in CLI/worker contexts (see Gotchas).Psl\Http\Client — hardened significantly in 6.3.0 (see below).use Psl\Type;
final readonly class CreateOrderData
{
public function __construct(
public string $sku,
public int|float|string $amount, // numeric() preserves input type
) {}
public static function fromArray(array $input): self
{
$schema = Type\shape([
'sku' => Type\non_empty_string(),
'amount' => Type\numeric(),
'note' => Type\optional(Type\string()),
]);
$data = $schema->coerce($input);
return new self($data['sku'], $data['amount']);
}
}
coerce() returns a typed value or throws; assert() verifies without coercion; matches() is the silent boolean check:
if (!Type\numeric()->matches($request->input('amount'))) {
// fall back / return validation error
}
FormRequest::passedValidation() or a small DataFactory service and let the thrown Type\Exception\ExceptionInterface be mapped to a 422 by an exception handler. Keep Laravel's Validator for user-facing field messages; use Psl\Type where you want typed output, not just errors.Type\numeric() (6.3.0): accepts int, float, and numeric string, and preserves the input type (numeric strings stay strings). Coercion also accepts Stringable whose string form is numeric. Use it for HTML form fields/query strings where everything arrives as a string, then cast explicitly at the boundary:
$amount = $data['amount']; // '1.23' | 1.23 | 42
$decimal = (float) $data['amount']; // be explicit downstream
Don't reach for it when you do want coercion to int/float — keep using Type\int(), Type\float(), or a Type\converted() wrapper so type intent stays visible in the signature.vec/dict guarantee rather than a fluent object:
use Psl\Vec;
use Psl\Dict;
$ids = Vec\map($rows, fn(array $r) => $r['id']);
$active = Vec\filter($rows, fn(array $r) => $r['active'] === true);
$indexed = Dict\from_entries(Vec\map($rows, fn($r) => [$r['id'], $r]));
$models->all() or ->toArray() — then go back to collect() when you need Eloquent behaviour. Don't mix Collection side effects into a Vec pipeline.Dict\diff() (6.3.0): PHPDoc value bound widened to scalar|null|resource|Stringable to match the string comparison it already performs at runtime. Runtime behaviour is unchanged — but your Psalm/PHPStan baseline may shift, so re-run static analysis after the bump.Psl\Http\Client now validates all Transfer-Encoding response fields, including repeated ones. Invalid or unsupported transfer codings raise ProtocolException instead of silently falling back to Content-Length. Valid chunked still wins over Content-Length.Content-Length fields are rejected in HTTP/1.1 responses and HTTP/2 headers (identical duplicates still accepted).use Psl\Http\Client;
use Psl\Http\Exception\ProtocolException;
try {
$response = $client->get($url);
} catch (ProtocolException $e) {
report($e); // terminal: malformed or hostile upstream
throw $e; // do NOT retry blindly
}
Keep retries/timeouts for transport errors (IO, timeouts); treat ProtocolException as a signal to alert, not to fall back to another code path.use Psl\Async;
$results = Async\concurrently([
'user' => static fn() => $client->get("/users/{$id}"),
'orders' => static fn() => $client->get("/users/{$id}/orders"),
], Async\NullCancellationToken::create());
Bus/Queue. Queues give durability, retries, backoff, and visibility; fibers give you concurrency within one process. A dropped fiber loses the job.Http::pool() first, Psl\Async second.Async\sleep(), Async\IO) inside tasks — a blocking sleep()/file_get_contents() stalls the scheduler for every task.// app/Providers/PslServiceProvider.php
public function register(): void
{
$this->app->singleton(\Psl\Http\Client\ClientInterface::class, fn() => \Psl\Http\Client\create());
}
Bind the PSL client so outbound calls are swappable in tests (Psr18 adapter also exists if you'd rather keep one HTTP stack).
matches(), not validate() — TypeInterface exposes matches(mixed): bool, coerce(), and assert(). There is no validate() method and no Type\register() registry; compose new types by implementing Psl\Type\TypeInterface (or wrapping an existing one) rather than mutating a global registry.Type\numeric() preserves type — '42' stays '42', 42 stays 42. If your DTO property is int, numeric() alone won't get you there. Choose Type\int() / Type\float() for real coercion, Type\numeric() for "numeric-ish, keep as-is".Type\numeric() + Stringable — coercion accepts objects whose __toString() is numeric, and you get the string back, not the object. If you need the object downstream, keep the original alongside.coerce() throws — wrap at the boundary (a single DataFactory / exception handler mapping), not in every controller. Exception hierarchy lives under Psl\Type\Exception\*.shape() requires every key unless wrapped in Type\optional(). Missing keys throw InvalidShapeKeyException; unexpected keys throw UnknownShapeKeyException. Trim input first if you're passing $request->all().Vec\map(), Vec\filter(), Dict\* return new arrays; nothing mutates in place. Vec\filter() reindexes, Dict\filter() preserves keys — pick deliberately.Dict\diff() PHPDoc change (6.3.0) — widened to scalar|null|resource|Stringable. Runtime unchanged, but expect new @psalm-*/PHPStan messages on mixed-type dicts. Re-baseline.ProtocolException is not transient — see above. Don't add it to a retry list.try {
$data = $schema->coerce($input);
} catch (\Psl\Type\Exception\ExceptionInterface $e) {
Log::warning('type.coerce.failed', ['path' => $e->getPath(), 'input' => array_map('gettype', $input)]);
throw $e;
}
Type\TypeInterface::toString() gives a readable representation for debug logs — handy when a shape error names a nested path.try/catch and remember one failing task can cancel siblings depending on the primitive used — use TaskGroup when you want per-task results, concurrently() when all-or-nothing is fine.Psl\Type\TypeInterface (or use Type\converted() for a cheap map/validate pair):
$priceType = Type\converted(
Type\numeric(),
static fn(int|float|string $v) => Money::of((string) $v, 'EUR'),
);
TaskGroup with explicit catch per awaitable; avoid global hooks.Psl\Http\Client\ClientInterface and Psl\Type\TypeInterface factories so tests can substitute fixtures.coerce() results — worth the setup cost given how much of the value is in typed output.
# psalm.xml
<plugins>
<pluginClass class="Psl\Integration\Psalm\Plugin"/>
</plugins>
composer update php-standard-library/php-standard-library, run Psalm/PHPStan (expect Dict\diff() docblock deltas), then exercise all outbound Http\Client integrations against staging — the Transfer-Encoding / duplicate Content-Length rejection is the only behaviour change worth a regression pass.How can I help you explore Laravel packages today?