tiben/crontab-manager
Manage server cron jobs from a Laravel web UI. Add, edit, enable, disable, and delete crontab entries, run jobs on demand, and review logs without touching the command line. Supports multiple cron types and per-job scheduling controls.
tiben/crontab-manager is not a Laravel package — there is no service provider, no config file, no facade, no auto-discovery. It is a plain PHP wrapper around the crontab binary that shells out via exec(), parses crontab -l, and lets you add/remove job objects before writing the whole table back with crontab -. Treat it as a low-level system utility you wire up yourself.
Install and pin it (last release 2023-05-02, single-maintainer project):
composer require tiben/crontab-manager:^4.0
Confirm your environment before writing any code:
which crontab # must resolve
crontab -l # must be readable by the user running PHP
php -r 'var_dump(function_exists("exec"));'
Then bind the manager as a singleton and do one read-only smoke test before any writes:
// app/Providers/AppServiceProvider.php
use TiBeN\CrontabManager\CrontabAdapter;
use TiBeN\CrontabManager\CrontabManager;
$this->app->singleton(CrontabManager::class, fn () => new CrontabManager(new CrontabAdapter()));
// tinker
$crontab = app(\TiBeN\CrontabManager\CrontabManager::class);
foreach ($crontab->getJobs() as $job) {
dump($job->name, $job->command);
}
First real use case: not running your Laravel schedule. Laravel's scheduler is already one static cron line (* * * * * cd /var/www/app && php artisan schedule:run); replacing it with this library is a downgrade. The genuinely useful case is managing entries that live outside Laravel's schedule: per-tenant dynamic jobs written at runtime, provisioning cron for a system user during deploy, or cleaning up legacy entries a previous team left behind. Start there — an artisan command like cron:sync {tenant} that upserts one named, idempotent entry — and keep it out of HTTP request paths entirely.
The v4 API shape. CrontabManager replaces the older 3.x CrontabRepository, and setters became public properties:
use TiBeN\CrontabManager\CrontabJob;
$job = new CrontabJob();
$job->name = 'app-tenant-42-report'; // becomes a `# name` comment line
$job->command = '/usr/bin/php /var/www/app/artisan tenant:report 42 >> /var/www/app/storage/logs/tenant-42.log 2>&1';
$job->minute = '0';
$job->hour = '3';
$job->dayOfMonth = '*';
$job->month = '*';
$job->dayOfWeek = '*';
$job->enabled = true; // false = commented out
$crontab->add($job);
$crontab->save(); // rewrites the ENTIRE table
If you're on 3.x, everything reads $repo = new CrontabRepository(new CrontabAdapter()), $job->setJob(...), $repo->persist(). Pick a major version and stay there — upgrading is a rewrite, not a bump.
Idempotent upsert — the only pattern you should ship. add() does not guarantee dedupe, and this library rewrites the whole file, so a duplicate entry silently runs your job twice. Resolve by name first:
public function sync(CrontabJob $desired): void
{
$this->crontab->replace($desired, function (CrontabJob $existing) {
$this->crontab->remove($existing);
});
}
// or explicitly:
foreach ($this->crontab->getJobs() as $existing) {
if ($existing->name === $desired->name) {
$this->crontab->remove($existing);
}
}
$this->crontab->add($desired);
$this->crontab->save();
Namespace your jobs by name prefix. Since name is purely a comment, it's your only ownership marker. Use app-tenant-{id}-{purpose} so a cron:prune command can safely remove str_starts_with($job->name, 'app-') and never touch hand-added system entries. Never write a removal loop that clears the whole table.
Wrap it in a domain-level interface before you use it twice. The library is thin enough that you will want to swap it or fake it:
interface CrontabGateway {
public function upsert(string $name, string $command, string $schedule): void;
public function remove(string $name): void;
}
final class CrontabManagerGateway implements CrontabGateway { /* ... */ }
// tests / local dev
final class InMemoryCrontabGateway implements CrontabGateway { public array $jobs = []; }
This also lets you keep the package out of phpunit.xml territory — unit tests get the in-memory double, and only a tagged integration test on a real Linux box touches the binary.
Serialize writes. The read-modify-write cycle has no locking, so two concurrent save() calls will clobber each other and drop entries. Guard every write:
Cache::lock('crontab:write', 10)->block(5, function () use ($job) {
$this->gateway->upsert($job->name, $job->command, $job->schedule);
});
Invoke it from CLI only. The user running php-fpm (www-data) almost never has a crontab, and crontab -l for that user either errors or returns empty. Run mutations from an artisan command in a deploy hook, from a queue worker (same user as artisan), or as a one-off artisan run in the release script — never from a controller or a model observer.
Keep it out of the scheduler's critical path. If a tenant needs a dynamic schedule, prefer generating the schedule inside Laravel:
// routes/console.php
foreach (Tenant::whereNotNull('report_at')->cursor() as $tenant) {
Schedule::command("tenant:report {$tenant->id}")->cron($tenant->report_at);
}
Reach for crontab-manager only when the job must run outside the app's user context, or when you're provisioning a system/root crontab — in which case you'll be writing to /etc/cron.d/ yourself anyway, and this library doesn't help.
The user context is the single most common failure. The library manipulates the invoking user's crontab. It does not do sudo, does not write /etc/cron.d/, and does not know about system users other than the current one. A job that works from php artisan will silently fail from a controller.
exec() may be disabled. Shared hosts and hardened images routinely set disable_functions=exec. The adapter will throw a CrontabException that reads like a parse failure. Assert early:
if (! function_exists('exec') || ! shell_exec('command -v crontab')) {
throw new \RuntimeException('crontab-manager requires exec() and the crontab binary.');
}
Full-table rewrite means formatting loss. Every save() re-serializes everything the parser understands. Hand-written alignment, comments without a following job, and unusual env lines can be reordered or dropped. Before enabling in production, do a crontab -l > before.txt, run one sync, crontab -l > after.txt, and diff. Keep a backup cron entry of your own.
% is special in cron. A literal percent in your command becomes a newline unless escaped as \%. Password arguments, date +%F, and URLs with encoded chars will bite you. Quote the whole command and escape defensively.
Cron runs /bin/sh, not bash, with a minimal PATH. Always absolute paths (/usr/bin/php, not php), no .env inheritance, no source. Laravel's artisan needs cd or --realpath; both are safest as cd /var/www/app && /usr/bin/php artisan ....
Uncaptured output becomes mail. Without >> /path/log 2>&1 cron tries to email it; on a container with no MTA you get silent loss. Set MAILTO="" at the top of the table or always redirect.
name is cosmetic. It's a comment; cron ignores it. If someone edits the file by hand, name-based lookup and removal break. Treat hand-editing as unsupported and document it.
License needs verification before commercial use. The repository metadata reports NOASSERTION, and upstream is widely cited as GPL-3.0. A GPL dependency in a proprietary SaaS/CLI is a legal conversation, not a code review comment. Confirm with your legal owner, or isolate the package behind a separate process/artifact.
Version and maintenance risk. 138 stars, zero known dependents, last release May 2023. Pin exactly ("tiben/crontab-manager": "4.0.*"), keep the surface area to two or three calls, and keep your CrontabGateway interface so replacing it with a hundred lines of exec() is a one-afternoon job.
Container and macOS caveats. On macOS you get BSD cron semantics; in slim Docker images crontab may not be installed (apt-get install cron) and the daemon must actually be running (cron -f) or nothing fires regardless of what the table says. Windows is unsupported.
Debugging tip: the temp file the library writes is the ground truth. When a save misbehaves, capture the array from getJobs() and the raw crontab -l output side by side — most "the package is broken" reports are a parser/format mismatch that the raw strings reveal immediately.
How can I help you explore Laravel packages today?