tecnickcom/tc-lib-file
PHP utilities for low-level file access and byte-level reading. Provides local and remote file reads with host/path allowlists, big-endian binary byte readers, a prefixed temp-file cache, and typed exceptions. Part of the TCPDF ecosystem. Requires PHP 8.2+ with curl and pcre.
1. Install and verify the platform floor
composer require tecnickcom/tc-lib-file
This is not a Laravel package — there is no service provider, facade, migration, or config file. It is a plain PHP library that drops into vendor/ and is auto-loaded. Check two things before going further:
php -v is 8.2+ (this alone disqualifies older Laravel 8/9 apps).ext-curl and ext-pcre are present (php -m); ext-intl is optional but matters on macOS for Unicode path folding.2. Look at the source, not the README
The API surface is tiny — five classes in src/. Read them in this order:
src/File.php — the only class with meaningful configuration; everything else is a leaf utility.src/Byte.php — ~100 lines of bounds-checked big-endian readers.src/Cache.php — temp-file naming and cleanup.src/Dir.php, src/Exception.php — trivial.The generated API docs at https://tcpdf.org/docs/srcdoc/tc-lib-file are the only reference; there are no guides, no recipes, and no Laravel examples.
3. Expect the first call to fail (by design)
File is strict-deny: allowedHosts and allowedPaths both default to [], so your first fopenLocal() on a storage_path() file will be rejected. The minimum viable wire-up in a Laravel app is a singleton bound with config-backed allowlists:
// config/tcfile.php
return [
'allowed_paths' => [storage_path('app/imports'), storage_path('app/private')],
'allowed_hosts' => ['partner.example.com'],
];
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->bind(\Com\Tecnick\File\File::class, fn () => new \Com\Tecnick\File\File(
allowedHosts: config('tcfile.allowed_hosts'),
allowedPaths: config('tcfile.allowed_paths'),
curlopts: [CURLOPT_MAXREDIRS => 3],
), );
}
4. First real use case
The one thing this library does that Laravel's Storage does not: structured byte reads from a binary stream. A realistic day-one task is sniffing a file's true type or pulling a header field out of an upload:
$fh = $file->fopenLocal(Storage::path("imports/{$name}"), 'rb');
$sig = (new \Com\Tecnick\File\Byte(fread($fh, 16)))->getULong(0);
If your need is "read a file," use Storage. If it is "read bytes of a file with bounds checking and typed exceptions," this is the tool.
Bind once, treat as read-only config. The File instance is mutable (setAllowedHosts(), setAllowedPaths(), setCaseSensitivePaths()), which is a hazard under Octane or long-running workers. Either bind it as a scoped instance and never mutate it after construction, or construct it per job. Prefer the constructor arguments over setters for exactly this reason.
Wrap it behind an app-level adapter. Nothing about this package is Laravel-idiomatic, and its API (by-reference validators, false returns, SPL vs. library exceptions) leaks. A thin BinaryFileReader service that exposes header(string $path, int $offset): int lets you unit-test with Storage::fake() and swap implementations later.
Parse binaries with Byte, not with unpack() scattered around. Byte is immutable, validates every read against the string length, and throws \RangeException on overrun. This is genuinely nicer than raw unpack() for font/image/custom-format parsing:
$byte = new \Com\Tecnick\File\Byte($binary);
if ($byte->getUShort(0) === 0x4D5A) { // "MZ"
$offset = $byte->getULong(0x3C); // PE header pointer
}
// 32-bit readers assume 64-bit PHP — check PHP_INT_SIZE if you target 32-bit.
Aliases exist for the OpenType crowd (getUFWord/getFWord), which is a hint at who actually consumes this.
Uploads: skip fopenLocal() on the temp path. UploadedFile::getRealPath() points into upload_tmp_dir/sys_get_temp_dir(), which is not in your allowedPaths. Adding the system temp dir to the allowlist is broad trust you probably do not want. Read the content with $request->file('x')->get() and hand it to Byte, or copy the upload into an allowlisted directory first:
$path = $request->file('doc')->store('imports', 'local'); // allowlisted root
$fh = $file->fopenLocal(Storage::path($path), 'rb');
Remote fetches: use this when the allowlist is the feature. getUrlData()/fileGetContents() go through cURL regardless of allow_url_fopen, buffer in memory, and are bounded by maxRemoteSize (50 MB default). That is a genuinely useful pattern for "pull a partner's logo/PDF from a fixed set of hosts" in a job with no egress policy elsewhere:
$file = new \Com\Tecnick\File\File(
allowedHosts: ['cdn.partner.com', 'downloads.partner.com'],
curlopts: [CURLOPT_MAXREDIRS => 5, CURLOPT_TIMEOUT => 10],
);
$pdf = $file->getUrlData('https://cdn.partner.com/invoice/1234.pdf');
For general outbound HTTP, use the Http facade. Reach for this only when you want a hard host allowlist plus a compressed-size-independent memory ceiling in one call.
Cache: use it for derived artifacts, and always keep the returned path. getNewFileName() carries a CSPRNG suffix; $type/$key are deletion scopes, not lookup keys. You cannot re-derive a filename later.
$cache = (new \Com\Tecnick\File\Cache('thumbs'))
->setCachePath(storage_path('app/tc-cache'));
$path = $cache->getNewFileName('image', 'logo'); // store this in the DB / response
file_put_contents($path, $webp);
Clean up on a schedule rather than inline — deleteOlderThan() is the only sane long-term story:
// app/Console/Commands/PruneBinaryCache.php (Schedule::command(...)->hourly())
(new \Com\Tecnick\File\Cache('thumbs'))
->setCachePath(storage_path('app/tc-cache'))
->deleteOlderThan(86400);
Dir::findParentDir() is a TCPDF-compat shim, not an app utility. It exists so PDF/font stacks can locate a writable cache/ folder without K_PATH_CACHE. In a Laravel app you already know your writable path; only pull this in when integrating legacy TCPDF-era code.
Testing. Byte needs no fixtures beyond strings — excellent for table-driven PHPUnit tests. For File, pass caseSensitivePaths: true|false explicitly so allowlist tests pass on any CI runner, and point allowedPaths at a Storage::fake() root.
false return or a \Com\Tecnick\File\Exception, not a warning. Log the rejected path/host alongside the failure or you will be debugging blind.isValidURL() and isValidFile() take arguments by reference and rewrite them (trim, prepend file://). They only accept variables — passing a literal is a fatal error. Use isAllowedUrl() / isAllowedFile() for literals and expressions; those are pure booleans.allowedPaths stores both literal and realpath() forms, which saves you when storage_path() sits behind public/storage or a current -> releases/42 deploy symlink. But on macOS /tmp and /var canonicalize to /private/...; if you build allowlists from env vars in a container, verify with getCachePath()-style introspection rather than assuming.caseSensitivePaths explicitly in Docker/K8s deployments.CURLOPT_MAXREDIRS defaults to 0, which means no redirects at all. A 3xx is reported as unreadable — and critically, the 3xx body is never returned as content (this matters when open_basedir disables CURLOPT_FOLLOWLOCATION). Each followed Location is re-validated against allowedHosts, so redirect targets need to be listed too.CURLOPT_WRITEFUNCTION, CURLOPT_PROGRESSFUNCTION, CURLOPT_XFERINFOFUNCTION, CURLOPT_NOPROGRESS, and (with redirects on) CURLOPT_HEADERFUNCTION. Don't try to implement streaming downloads by passing a write callback — it will be overwritten. The library buffers in memory by design.maxRemoteSize counts inflated bytes. A gzipped response is measured after decompression, which is the safe direction, but a legitimate 40 MB PDF that compresses to 5 MB still counts as 40 MB.Cache strips _ (and everything outside [A-Za-z0-9-]) from prefix/type/key; + and / become - so base64 prefixes survive. An empty prefix is replaced by a random one. Consequence: Cache('app') and Cache('app_v2') do not collide on deletion — but also, you cannot reconstruct names by concatenation. Always persist what getNewFileName() returns.delete(null, $key) throws (a key needs a type), and deleteOlderThan(-1) throws rather than deleting everything. Both are deliberate footguns removed.setCachePath() falls back silently to K_PATH_CACHE, then upload_tmp_dir, then the system temp dir. Compare getCachePath() to what you passed if cache location matters (it usually does — temp dirs get wiped).\Com\Tecnick\File\Exception; Byte throws SPL \RangeException for out-of-bounds reads. A generic catch (\Com\Tecnick\File\Exception) will miss truncated-file errors. Catch both in parser code.fopenLocal() force-adds the b flag, so Windows CRLF translation won't corrupt byte reads — but a stream you opened yourself and passed elsewhere will not get that treatment.bool validators honest. If you rely on parse_url() elsewhere, this library is stricter — do not assume parity.PROGRA~1), alternate data streams (file.txt:stream), trailing dots/spaces, and reserved device names are treated as untrusted literals. Allowlisting C:\data will not match a path spelled C:\PROGRA~1\data.tc-lib-pdf/TCPDF (100M+ installs), so the real usage is invisible in the dependents count but the version metadata is worth verifying with composer show -a tecnickcom/tc-lib-file before pinning. You likely already have it installed if TCPDF is in the tree; do not declare a conflicting constraint.CURLOPT_VERBOSE temporarily via setCurlOpts() (it is not in the reserved list) and capture with CURLOPT_STDERR. Remember that unreadable URLs return false, not an exception, so distinguish "blocked by allowlist" from "network error" by checking isAllowedUrl() first.BinaryFileReader wrapping Byte) rather than hoping for injection points. Treat the package as a sealed primitive and keep your Laravel-facing code outside it.How can I help you explore Laravel packages today?