hoa/file
Hoa\File is a PHP library for robust filesystem access, offering object-oriented abstractions for files, directories, links, sockets, and streams. It supports reading, writing, locking, permissions, iterators, and exceptions, making file operations safer and more consistent.
hoa/file is a standalone OOP wrapper around PHP's stream functions. There is no service provider, facade, config file, or publishable asset — it is plain PHP objects you new up. That is the whole integration story, and it is also the main reason to think twice before adopting it.
Install
composer require hoa/file
This pulls in the Hoa dependency chain (hoa/stream, hoa/consistency, hoa/exception, hoa/event, hoa/protocol). All are abandoned; pin the resolved versions in composer.lock deliberately.
Look here first
Hoa\File\Generic — the method surface every other class inherits: open(), close(), read(), readLine(), readAll(), write(), writeLine(), seek(), tell(), eof(), lock(), unlock(), copy(), move(), delete(), getSize(), getMTime().Read, Write, ReadWrite, Temporary, Directory, Link, Finder.Hoa\File\Finder only if you don't already have symfony/finder (you do — it ships with Laravel).First use case: stream a file instead of file_get_contents()
use Hoa\File\Read;
$file = new Read(storage_path('app/imports/customers.csv'));
try {
while (!$file->eof()) {
$line = rtrim($file->readLine(), "\r\n");
// process one row, constant memory
}
} finally {
$file->close();
}
Writing
use Hoa\File\Write;
$out = new Write(storage_path('app/reports/summary.txt'));
$out->writeLine('rows: ' . $count);
$out->close();
Verify from php artisan tinker before wiring it into a command or job. If that two-minute smoke test fails on your PHP version, stop there — see Gotchas.
Don't scatter new Read(...) through controllers. One factory class gives you a single place to swap in native PHP later when you drop the package.
namespace App\Services;
use Hoa\File\Read;
use Hoa\File\Write;
use Hoa\File\ReadWrite;
class HoaFile
{
public function read(string $absolutePath): Read
{
return new Read($absolutePath);
}
public function write(string $absolutePath): Write
{
return new Write($absolutePath); // truncates on construct — see Gotchas
}
public function append(string $absolutePath): ReadWrite
{
return new ReadWrite($absolutePath, null, ReadWrite::MODE_APPEND);
}
}
Bind it in AppServiceProvider::register() as a singleton if it is stateless (it is). Do not build a Facade for it; injection is clearer and keeps the dependency visible.
Hoa only understands local streams. Use Laravel to resolve the path, Hoa to do the I/O:
$path = Storage::disk('local')->path("imports/{$id}.csv");
$file = (new HoaFile)->read($path);
This does not work for s3, ftp, or any non-local disk — those need Storage::readStream(). Keep the boundary explicit.
The one genuinely useful pattern: constant-memory reads plus resumability via seek()/tell().
public function handle(HoaFile $files): void
{
$file = $files->read(Storage::disk('local')->path($this->relativePath));
$offset = $this->resumeOffset;
try {
if ($offset > 0) {
$file->seek($offset);
}
while (!$file->eof()) {
$this->ingest(rtrim($file->readLine(), "\r\n"));
$this->resumeOffset = $file->tell();
}
} finally {
$file->close();
}
}
Persist resumeOffset every N rows so a retried job skips already-processed data.
Hoa's lock()/unlock() are the reason some teams reach for this package at all:
$fh = (new HoaFile)->append(storage_path('logs/audit.log'));
try {
$fh->lock(\LOCK_EX);
$fh->writeLine(json_encode($event));
} finally {
$fh->unlock();
$fh->close();
}
LOCK_EX + finally guarantees workers don't write interleaved lines. Only trustworthy on a local filesystem — see Gotchas.
Finder inside an Artisan commandIf you're already committed to Hoa, using its Finder keeps one style in the command:
$finder = (new \Hoa\File\Finder())
->in(storage_path('app/inbox'))
->name('*.csv');
foreach ($finder as $file) {
$this->line('queued: ' . $file->getRealPath());
ImportJob::dispatch($file->getRealPath());
}
If you are not already committed, use symfony/finder — identical API surface, actively maintained.
Hoa hits the real filesystem, so Storage::fake('local') is your friend; combine with Storage::path() and write to that directory. Close every handle in tearDown() — a leaked handle causes "too many open files" in the test process and masks the real failure.
Last release 2017-07-11, zero dependents, no maintainer. The code targets PHP 5.5–7.x. Before committing any of the above, run your suite on your actual runtime (PHP 8.1/8.2/8.3): expect deprecation notices and possibly fatals inside hoa/consistency and hoa/event before hoa/file is even reached. If the smoke test fails, the correct decision is SplFileObject or Illuminate\Filesystem\Filesystem — they cover readLine, seek, tell, eof, and flock with zero dependencies.
Hoa\File\Exception extends Hoa\Exception\Exception, not RuntimeException. It will not be classified by your Handler the way Laravel exceptions are. Catch at your service boundary and rethrow:
try {
$file->readAll();
} catch (\Hoa\Exception\Exception $e) {
throw new \RuntimeException("Cannot read {$path}", 0, $e);
}
Hoa's exceptions also carry rich internal attributes — log (string) $e rather than $e->getMessage() if you need the full trace context.
new Write($path) truncates on constructionThe file is opened in wb mode in the constructor, before you write a single byte. Never use Write for read-modify-write: if the process dies between construct and write you have an empty file. Use ReadWrite with MODE_APPEND, or write to Hoa\File\Temporary and move() into place.
hoa:// protocolRelative paths are not resolved against base_path(). Under php artisan serve the CWD is public/; under a queue worker it's the project root. Always pass absolute paths from base_path(), storage_path(), or Storage::path(). If hoa:// entries appear in stack traces, someone is relying on Hoa\Protocol resolution — a singleton that will persist across requests under Octane. Don't mutate it at runtime.
flock()-based locks are fine on ext4/APFS, unreliable-to-broken on NFS and meaningless on object storage. lock() without a matching unlock() in finally will wedge a queue worker on the handle. Treat the pair as strictly bracketed.
writeLine() appends the value of Generic::getNewLine() (\n on Unix, \r\n on Windows), and readLine() keeps the terminator. On a mixed dev/prod fleet this silently changes file bytes between environments. rtrim() on read; pass explicit "\n" when you control the format. Windows CI will catch this, nothing else will.
readAll() is file_get_contents() with more ceremonyIt materialises the whole file in memory. For anything that might exceed memory_limit, loop eof()/readLine() or read() with a fixed-length buffer (setStreamBucketSize() tunes the underlying stream bucket). Also note every operation dispatches an event through hoa/event — benchmark against raw fgets before putting this in a hot loop over millions of rows; it is measurably slower.
Generic::__destruct() closes the stream, but destructors are lazy in long-running workers and a throw during shutdown is ugly. Close explicitly in finally, every time. The underlying resource is reachable through the stream layer (getStream()) if you need stream_get_meta_data() to debug a stubborn handle.
When Hoa throws, check in this order: (1) resolved absolute path is what you think — dd($path); (2) is_file() / is_readable() / parent dir is_writable(); (3) the file isn't already open elsewhere with LOCK_EX; (4) PHP's error_get_last() — Hoa sometimes lets a native warning fire before wrapping it. Hoa\File\Directory and Hoa\File\Link exist but add nothing over RecursiveDirectoryIterator/readlink().
Subclass Hoa\File\Generic (or Read/Write) to attach domain behaviour — e.g. CsvWrite extends Write with a typed writeRow(array $row). Custom stream_wrapper_register() wrappers are picked up by Hoa's stream layer. These are real extension points; just remember you're extending abandoned code, so keep the subclass thin enough to port to SplFileObject in an afternoon.
Five abandoned transitive packages, no security process, composer audit will be quiet only because nobody is looking. If you keep this, consider vendoring/forking under a private namespace so a future composer update doesn't silently break — and add a // TODO: replace with SplFileObject on the service class the day you write it.
How can I help you explore Laravel packages today?