mapsight / pulp
Core library providing the stream-based processing engine.
Requires
- php: ^8.2
- ext-mbstring: *
- guzzlehttp/guzzle: ^7.8
- symfony/polyfill-php83: ^1.37
- symfony/polyfill-php84: ^1.37
- symfony/polyfill-php85: ^1.37
Requires (Dev)
- mikey179/vfsstream: ^1.6.12
- phpunit/phpunit: ^11.0
- roave/security-advisories: dev-latest
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Stream-like file processing for PHP, inspired by Gulp.
Pulp moves File objects through a chain of handlers. Source handlers create files, transform handlers edit or replace them, branching handlers fan work out, and destination/result handlers write or collect the final output.
Features
- Pipeline API: Compose file processing as
Pulp::start()->pipe(...)->run(). - Virtual files: Handlers pass
OpenMapsight\pulp\Fileobjects withfileName,srcFileName, and dynamic metadata. - Lazy file content: Files from disk are not read until
$file->contentis accessed. - Stream access: Large-file handlers can call
$file->stream()to read without loading full content. - Branching: Use
split,merge,shadow, andfileSwitchfor common flow-control patterns. - Extensible: Implement handlers directly or build package-level helpers around them.
Quick Start
use OpenMapsight\Pulp; use OpenMapsight\pulp\File; Pulp::start() ->pipe(Pulp::src('.*\.txt', __DIR__ . '/input')) ->pipe(Pulp::map(static function (File $file): File { $file->content = strtoupper($file->content); return $file; })) ->pipe(Pulp::dest(__DIR__ . '/output')) ->run();
Core Concepts
Pipelines
A pipeline is a chain of handlers:
Pulp::start() ->pipe(Pulp::src('.*\.json', __DIR__ . '/data')) ->pipe(/* handler */) ->pipe(Pulp::dest(__DIR__ . '/result')) ->run();
run() starts the chain. If no source handler is present, you can pass files directly:
$file = new File('example.txt'); $file->content = 'Hello'; $results = Pulp::start() ->pipe(Pulp::map(static fn(File $file): File => $file)) ->run($file);
Files
OpenMapsight\pulp\File represents a virtual file.
$file->fileName: the pipeline-relative file name.$file->srcFileName: the original source name/path.$file->content: dynamic content property. For files sourced from disk, this lazy-loads the full file on first access.$file->stream(): returns a readable stream. For path-backed files this opens the source file directly; for generated string content it creates a temporary stream.- Additional properties can be attached dynamically, for example
$file->statsor$file->isLogicallyEmpty.
Use $file->content for normal transforms. Use $file->stream() in handlers that need to process large files without loading them completely.
API
Pulp::start()
Creates an empty pipeline.
$pulp = Pulp::start();
Pulp::src($patterns, $directory = '.')
Creates files from matching paths.
$patterns: a string, array of strings, file path, orFile.$directory: base directory for recursive matching.
Patterns are regular expressions matched against relative file names.
Pulp::start() ->pipe(Pulp::src('.*\.csv', __DIR__ . '/data')) ->run();
Pulp::srcFile($fileName, $aliasFileName = null, array $options = [])
Creates one file from a path. If $aliasFileName is provided, it becomes the virtual fileName.
Pulp::srcHttp($method, $uri, array $guzzleOptions, $aliasFileName, array $options = [])
Creates one file from an HTTP response body.
Pulp::start() ->pipe(Pulp::srcHttp( method: 'GET', uri: 'https://example.com/large.json', guzzleOptions: ['timeout' => 180], aliasFileName: 'large.json', options: ['sink' => true, 'successStatuses' => [200, 304, 204]], )) ->run();
sink => truewrites the body to a temp file and emits a path-backedFile, so a ~100 MB response does not have to live as a PHP string. The temp file is removed when the lastFilethat shares it is released, including clones fromsplit,shadow, andresults. Pass a path string to keep the body at a path you manage.clientinjects a GuzzleClient(tests use a mock handler).successStatusesthrows unless the status is in the list. Pair with Guzzlehttp_errors => falsewhen 304/204 are first-class.- The file also carries
httpStatus,httpLastModified,httpEtag, andhttpType.
Pulp::ensureDirectory($directory, $permissions = 0775)
Create $directory and its parents if needed, then return the path. Jobs use this for pulp/cache before writing sidecar files; dest uses the same helper internally.
$cacheDir = Pulp::ensureDirectory(__DIR__ . '/cache');
Pulp::dest($directory, array $options = [])
Writes incoming files into $directory using each file's fileName.
->pipe(Pulp::dest(__DIR__ . '/result'))
Common options:
flush: callfflush()after writing.skipExceptions: log write errors and continue.logSkipExceptions:stderr,stdout, orfalse.
Pulp::map($callback)
Transforms or drops files in plain PHP: one file in, at most one file out. For work that needs other handlers per file, use Pulp::each.
->pipe(Pulp::map(static function (File $file): ?File { if ($file->fileName === 'skip.txt') { return null; } $file->content .= "\n"; return $file; }))
Pulp::filter($callback)
Keeps only files where the callback returns truthy.
->pipe(Pulp::filter(static fn(File $file): bool => str_ends_with($file->fileName, '.json')))
Pulp::results($callback)
Collects all files that reach this handler and calls the callback at the end.
->pipe(Pulp::results(static function (array $files): void { // inspect results }))
Pulp::split(...$pipelines)
Feeds the same incoming files into multiple branch pipelines and merges each branch's results back into the main pipeline.
Use this when one source should produce several outputs.
Pulp::start() ->pipe(Pulp::src('.*\.txt', __DIR__ . '/input')) ->pipe(Pulp::split( static fn(Pulp $p): Pulp => $p->pipe(Pulp::map(static function (File $file): File { $file->fileName = 'upper-' . $file->fileName; $file->content = strtoupper($file->content); return $file; })), static fn(Pulp $p): Pulp => $p->pipe(Pulp::map(static function (File $file): File { $file->fileName = 'lower-' . $file->fileName; $file->content = strtolower($file->content); return $file; })), )) ->pipe(Pulp::dest(__DIR__ . '/result')) ->run();
Branches may be Pulp instances or callbacks receiving a new Pulp instance.
Pulp::merge(...$pulps)
Runs independent pipelines and merges their output.
$a = Pulp::start()->pipe(Pulp::src('.*\.json', __DIR__ . '/a')); $b = Pulp::start()->pipe(Pulp::src('.*\.json', __DIR__ . '/b')); Pulp::start() ->pipe(Pulp::merge($a, $b)) ->pipe(Pulp::dest(__DIR__ . '/result')) ->run();
Use merge for independent sources. Use split when you already have one incoming stream and want multiple outputs from it.
Pulp::shadow($callback)
Taps the stream into a side pipeline without changing the main stream.
->pipe(Pulp::shadow(static fn(Pulp $p): Pulp => $p ->pipe(Pulp::debug()) ))
shadow is useful for diagnostics or side effects. Its branch results are not merged back. Use split if branch output should continue downstream.
Pulp::fileSwitch(array $patterns, ?callable $defaultCb = null)
Routes files into different sub-pipelines by file name.
->pipe(Pulp::fileSwitch([ '.*\.json' => static fn(Pulp $p): Pulp => $p->pipe(/* JSON handlers */), '.*\.xml' => static fn(Pulp $p): Pulp => $p->pipe(/* XML handlers */), ], static fn(Pulp $p): Pulp => $p))
Pulp::each($callback, array $options = [])
Runs a pipeline for each incoming file and pushes its results instead of the file. The callback receives the file and returns the pipeline, which runs without input files. Use it when the work depends on the file, for example fetching a URL it names:
Pulp::start() ->pipe(Pulp::src('.*\.url', __DIR__ . '/feeds')) ->pipe(Pulp::each( static fn(File $feed): Pulp => Pulp::start() ->pipe(Pulp::srcHttp( method: 'GET', uri: trim($feed->content), guzzleOptions: ['timeout' => 30], aliasFileName: basename($feed->fileName, '.url') . '.json', )), ['skipExceptions' => true], )) ->pipe(Pulp::dest(__DIR__ . '/result')) ->run();
Start the pipeline with Pulp::src($file) to work on the file itself. Wrap a source in PulpCache::remember() inside the callback to keep the last good copy per file.
each or map?
mapchanges a file with plain PHP and returns it, another file, ornull. It is cheap and runs inline.eachbuilds and runs a whole pipeline per file. Use it when that work needs handlers (srcHttp,PulpCache::remember, package handlers), may emit several files, or should fail per file (skipExceptions).maphas noskipExceptions; an exception stops the run.- The pipeline of
eachstarts empty. Without a source handler orPulp::src($file)it emits nothing. - Handlers that collect files (
results,PulpGeoJSON::merge, …) insideeachsee one file's results only. Put them aftereachto collect across files.
Options:
skipExceptions: log a failing pipeline and continue with the next file.logSkipExceptions:stderr,stdout, orfalse.
Pulp::debug($length = 30)
Prints a short preview for each file.
Pulp::delete()
Deletes each file's srcFileName from disk.
Pulp::utf8encode() / Pulp::utf8decode()
Converts string content between ISO-8859-1 and UTF-8.
Pulp::serveHttp()
Sends file content as an HTTP response.
Common Patterns
Read, Transform, Write
Pulp::start() ->pipe(Pulp::src('.*\.txt', __DIR__ . '/input')) ->pipe(Pulp::map(static function (File $file): File { $file->content = trim($file->content) . "\n"; return $file; })) ->pipe(Pulp::dest(__DIR__ . '/output')) ->run();
Fan Out One Source Into Multiple Outputs
Pulp::start() ->pipe(Pulp::src('.*\.txt', __DIR__ . '/input')) ->pipe(Pulp::split( static fn(Pulp $p): Pulp => $p->pipe(/* branch A */), static fn(Pulp $p): Pulp => $p->pipe(/* branch B */), )) ->pipe(Pulp::dest(__DIR__ . '/output')) ->run();
Choose a Per-File Handler
| Handler | The callback gets | It returns | Files out per file | Use it for |
|---|---|---|---|---|
map |
the file | the file, another file, or null |
0 or 1 | changing content, name or properties in plain PHP |
filter |
the file | true to keep it |
0 or 1 | dropping files |
each |
the file | a pipeline, run without input | any | work that needs handlers per file, such as fetching what a file names |
fileSwitch |
a pipeline with the file as input | the pipeline | any | different handlers by file name |
split |
a pipeline with every file as input | the pipeline | any, per branch | several outputs from the same files |
Prefer map and filter when plain PHP does the job. Reach for each when the work depends on the file and needs other handlers.
Process Large Files With Streams
->pipe(Pulp::map(static function (File $file): File { $stream = $file->stream(); try { while (($line = fgets($stream)) !== false) { // Process without loading the full file. } } finally { fclose($stream); } return $file; }))
Writing Custom Handlers
Most handlers extend OpenMapsight\pulp\AbstractHandler.
use OpenMapsight\pulp\AbstractHandler; use OpenMapsight\pulp\File; class UppercaseHandler extends AbstractHandler { public function onFile(File $file): void { $file->content = strtoupper($file->content); $this->pushFile($file); } }
Handlers can override:
onStart(): called before the first file.onFile(File $file): called for each file.onEnd(): called when the stream ends.
Use $this->pushFile($file) to pass output to the next handler. A handler may emit zero, one, or many files.
Constructor arguments are declared with getConstructorParamDefs() and accessed via $this->cp.
class PrefixHandler extends AbstractHandler { protected function getConstructorParamDefs(): array { return ['prefix']; } public function onFile(File $file): void { $file->content = $this->cp->prefix . $file->content; $this->pushFile($file); } }