ptachoire/tac
TAC (Tiny Active Record) is a lightweight, Composer-installable PHP ORM by ptachoire. It maps database tables to PHP objects through a simple ActiveRecord API, providing CRUD operations and basic query helpers with minimal configuration—a compact alternative to heavier ORMs for small projects.
ptachoire/tac is a micro-utility that gives you PHP-native tac (reverse line order) and tail (last N lines) behavior over local files, without shelling out to tac(1)/tail(1). That matters on shared hosting, Windows, or container images where those binaries aren't guaranteed, and inside Laravel where spawning a Process for every log read is wasteful.
Because adoption is essentially zero (1 star, 0 dependents, no release cadence), treat the source itself as the documentation. Budget your first hour as follows:
src/ end to end. This is a one-class-scale library; you can absorb the whole public surface in five minutes. Confirm the actual namespace (Tac\ per the composer mapping), the constructor signature, and whether it accepts a path, an SplFileObject, or a raw stream resource. That distinction decides whether it works with Storage::path() or only plain filesystem paths.composer.json constraints. Code of this vintage often pins php: >=5.3 or ^5.3|^7.0. Under PHP 8.2/8.3, expect each(), untyped SplFileObject iteration, or implicit resource handling to warn or fatal. Run it under your target PHP once before trusting it.Install for evaluation:
composer require ptachoire/tac
Then prove the one use case that justifies the dependency — reading the tail of a large log without loading it into memory:
use Tac\Tac; // verify actual FQCN from src/
$tac = new Tac(storage_path('logs/laravel.log'));
$lines = $tac->tail(50); // inspect the real signature; likely returns array or Generator
If the call returns a Generator, iterate it; if it returns an array, check whether it buffered the whole file to build that array — that's the difference between usable and unusable on a 500 MB log.
First real feature to wire up: a php artisan logs:tail command or an admin-only "recent errors" panel backed by this package. Keep it read-only, local-file, and non-critical.
The right way to consume this package in Laravel is not to call it from controllers. Wrap it behind an interface so the vendor namespace never leaks past app/Support.
1. Internal adapter interface. Define the contract you actually want, then implement it with the package:
// app/Contracts/LogReader.php
namespace App\Contracts;
interface LogReader
{
/** @return \Generator<string> */
public function tail(string $path, int $lines): \Generator;
/** @return \Generator<string> */
public function reverse(string $path): \Generator;
}
// app/Support/TacLogReader.php
namespace App\Support;
use App\Contracts\LogReader;
use Tac\Tac; // the ONLY place the vendor namespace appears
final class TacLogReader implements LogReader
{
public function tail(string $path, int $lines): \Generator
{
yield from (new Tac($path))->tail($lines);
}
public function reverse(string $path): \Generator
{
yield from (new Tac($path))->tac();
}
}
Bind it in a service provider ($this->app->bind(LogReader::class, TacLogReader::class)) and inject the interface everywhere. When you eventually replace it with a 60-line SplFileObject implementation, one binding changes and nothing else.
2. CLI command pattern. A diagnostic command is the safest integration surface because failures are visible to an operator, not a customer:
class TailLogsCommand extends Command
{
protected $signature = 'logs:tail {file=laravel.log} {--lines=100}';
public function handle(LogReader $reader): int
{
$path = storage_path('logs/'.$this->argument('file'));
if (! is_readable($path)) {
$this->error("Not readable: {$path}");
return self::FAILURE;
}
foreach ($reader->tail($path, (int) $this->option('lines')) as $line) {
$this->line(rtrim($line));
}
return self::SUCCESS;
}
}
3. Streaming to the browser. For a download/preview endpoint, pipe the generator through StreamedResponse so memory stays flat:
return response()->streamDownload(function () use ($reader, $path) {
foreach ($reader->tail($path, 500) as $line) {
echo $line;
}
}, 'recent.log', ['Content-Type' => 'text/plain']);
4. Uploaded-file preview. Right after $request->file('log')->store(...), resolve the absolute path with Storage::disk('local')->path($relative) and feed it to the reader. Keep this to bounded previews (last 100 lines); do not preview a 2 GB upload synchronously.
5. Testing pattern. Because the algorithm is the risk, not the wiring, write contract tests against the interface, and run them against both your adapter and any future replacement:
it('reads the last N lines without buffering', function () {
$path = tempnam(sys_get_temp_dir(), 'log');
file_put_contents($path, implode("\n", range(1, 10_000)));
$lines = iterator_to_array(app(LogReader::class)->tail($path, 3));
expect($lines)->toHaveCount(3)
->and(trim(end($lines)))->toBe('10000');
});
6. Integration tips.
->path() on the disk rather than hand-building storage_path(); it respects custom disk roots and keeps multi-driver code honest.Tac takes a path and re-fopens internally, resolve the path once and pass it in; do not loop re-resolving during a read (rotation race).follow mode the package might expose behind a config flag defaulted to off — a blocking read in a request cycle will exhaust PHP-FPM workers.The seekability assumption is the big one. Reverse reading requires either buffering the whole file (O(n) memory — fine for config files, fatal for multi-GB logs) or seeking backward from EOF in chunks. Determine which this package does before you ship. If it buffers, cap input size explicitly:
if (filesize($path) > 50 * 1024 * 1024) {
throw new LogReadException('File too large to reverse-read safely.');
}
Missing trailing newline. A file that ends in ...last line with no \n is the classic off-by-one: naive reverse scanners drop the final line. Add a fixture to your tests. Same for empty files (should yield nothing, not throw) and single-line files.
CRLF and multibyte encoding. Splitting on \n alone leaves \r on every line on Windows-origin files, and seeking mid-stream can split a UTF-8 code point, producing mojibake. If encoding matters, normalize output or explicitly document that only UTF-8/LF is supported.
Non-seekable sources. Storage::disk('s3'), gzip streams, and pipes cannot seek. There is no evidence this package handles them, and the likely behavior is undefined. Restrict the package to local, seekable disks and throw a clear domain exception otherwise — do not let callers discover this by timeout.
Rotation races. Between stat() and fopen() the inode can swap. If the package resolves the path more than once mid-read, you can get a half-old, half-new file. Resolve once, open once, and treat the handle as immutable for the read.
PHP 8 compatibility. Run it under your actual PHP version at install time. Expect deprecation warnings from each() or untyped SplFileObject usage. If it's unclean, run the vendored copy through Rector + PHPStan before it enters a branch.
Pin properly. With no meaningful releases, do not depend on a floating branch. Either pin a commit SHA in composer.json or — strongly preferred — copy the ~50–80 lines into app/Support/File/ReverseLineReader.php with the MIT header and upstream URL preserved for attribution. Given the size of the algorithm, vendoring removes supply-chain review entirely and makes the code yours to test.
Extension points to add yourself. The package won't give you:
limit/offset window beyond simple tail,ERROR lines) — filter the generator in your adapter,fseek yourself; don't try to bend the package into a -f implementation.Debugging. The failure modes are silent (truncated output, wrong line count) rather than loud. When something looks off, assert invariants in a test rather than debugging interactively: total lines for a known fixture, last line content, memory usage via memory_get_peak_usage() on a large sparse file, and behavior on 0444 permissions.
When to drop it entirely. If requirements grow past "last N lines of a local file" — follow mode, rotation handling, compression, remote storage, or multi-tenant access — replace the adapter with native tail -n via Process (guarded by platform checks) or a proper log-shipping setup. The interface you built in step 1 is what makes that a one-file change.
How can I help you explore Laravel packages today?