Building a PTO Calculator CLI in PHP
A few months ago I needed to answer a deceptively simple question: how many PTO days do I have left this year? Simple in theory — subtract used days from earned days — but the edge cases pile up fast. What if you started mid-year and accrue monthly? What about public holidays that fall in the middle of a PTO range? Part-time contracts? Carry-over caps?
Instead of opening a spreadsheet for the tenth time I built pto-cli: a PHP 8.4 command-line tool that handles all of it. This post walks through the design decisions, the architecture, and the code that makes it work.
What it needs to do
Before touching a single line of code I wrote down the rules the tool must handle:
- Accrue PTO either monthly (pro-rate by complete months worked) or yearly (pro-rate by calendar days worked in the year)
- Support part-time workloads — a 50% employee earns half the days
- Accept arbitrary PTO ranges including half-days
- Skip weekends and public holidays when counting used days
- Apply carry-over caps and optionally allow a negative balance
- Work entirely from the command line with no database or config file
Running it looks like this:
php bin/console pto:calculate \
--start=01.01.2025 \
--annual-pto=30 \
--accrual=monthly \
--pto=02.06.2025:06.06.2025 \
--pto=14.07.2025:18.07.2025 \
--holiday=de-BE
Output:
Remaining PTO: 19.0 days
Picking the right tools
This is a CLI application with no HTTP layer, so I reached for Symfony Console — the gold standard for PHP command-line apps. It handles argument parsing, help text, and interactive prompts with zero ceremony.
For dependency injection I used illuminate/container — Laravel's DI container — pulled in standalone without the rest of the framework. The container is tiny, has zero framework coupling, and its make() method handles constructor injection automatically. Wiring it up is a single file:
// config/app.php
$container = new \Illuminate\Container\Container();
$container->bind(
\Symfony\Component\Validator\Validator\ValidatorInterface::class,
fn() => \Symfony\Component\Validator\Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator()
);
$container->bind(HolidayProvider::class, GermanHolidayProvider::class);
$container->bind(AccrualPolicyResolver::class, function () {
return new AccrualPolicyResolver([
new MonthlyAccrualPolicy(),
new YearlyAccrualPolicy(),
]);
});
return $container;
This means the CalculatePTO command constructor lists its dependencies and the container injects them — no manual wiring needed in bin/console.
Value objects keep the domain clean
The core domain is modelled with four small immutable value objects. No setters, no mutation — just data passed through the calculation pipeline.
// src/Employee.php
class Employee
{
public function __construct(
public readonly DateTimeImmutable $startDate,
public readonly ?DateTimeImmutable $endDate,
public readonly float $workload
) {}
}
// src/PtoPolicy.php
class PtoPolicy
{
public function __construct(
public readonly float $annualPto,
public readonly AccrualPolicy $accrualPolicy,
public readonly int $carryOverMax,
public readonly bool $allowNegative
) {}
}
// src/PtoRequest.php
class PtoRequest
{
public function __construct(
public readonly DateTimeImmutable $from,
public readonly DateTimeImmutable $to,
public readonly bool $halfDay = false
) {}
}
Keeping these as readonly constructor-promoted properties means the entire state is visible at a glance and there is no way to accidentally mutate data mid-calculation.
The Strategy pattern for accrual policies
The most interesting design decision was how to handle the two accrual modes. Monthly accrual counts complete months worked within the year; yearly accrual counts calendar days worked as a fraction of the full year. The logic is genuinely different — a shared base class would create more coupling than it resolves.
The solution is a clean interface and two independent implementations:
// src/Accrual/AccrualPolicy.php
interface AccrualPolicy
{
public function calculate(Employee $employee, int $year, float $annualPto): float;
public function type(): AccrualType;
}
// src/Accrual/MonthlyAccrualPolicy.php
class MonthlyAccrualPolicy implements AccrualPolicy
{
public function calculate(Employee $employee, int $year, float $annualPto): float
{
$yearStart = new DateTimeImmutable("$year-01-01");
$yearEnd = new DateTimeImmutable("$year-12-31");
$from = max($employee->startDate, $yearStart);
$to = $employee->endDate ? min($employee->endDate, $yearEnd) : $yearEnd;
if ($from > $to) return 0;
$monthsWorked = (int) $from->diff($to)->m
+ ($from->diff($to)->y * 12);
return round(($monthsWorked / 12) * $annualPto * $employee->workload, 1);
}
public function type(): AccrualType
{
return AccrualType::Monthly;
}
}
The resolver is a simple map from the string flag value to the right policy object. If you pass an unknown type it fails fast:
// src/AccrualPolicyResolver.php
class AccrualPolicyResolver
{
/** @param AccrualPolicy[] $policies */
public function __construct(private array $policies) {}
public function resolve(string $type): AccrualPolicy
{
foreach ($this->policies as $policy) {
if ($policy->type()->value === $type) {
return $policy;
}
}
throw new \InvalidArgumentException("Unknown accrual policy: $type");
}
}
Adding a new accrual type — quarterly accrual, for example — means creating one new class that implements the interface and registering it in config/app.php. Nothing else changes.
Skipping weekends and public holidays
Counting used PTO days sounds trivial until you realise "10 June to 14 June" should be 5 days... unless a public holiday falls on Wednesday 11 June, in which case it is 4.
PtoUsageCalculator iterates each day in every request range and delegates the working-day decision to WorkingDayChecker:
// src/Calculator/PtoUsageCalculator.php
class PtoUsageCalculator
{
public function __construct(private WorkingDayChecker $checker) {}
/** @param PtoRequest[] $requests */
public function calculate(array $requests, array $holidays): float
{
$total = 0.0;
foreach ($requests as $request) {
$current = $request->from;
while ($current <= $request->to) {
if ($this->checker->isWorkingDay($current, $holidays)) {
$total += $request->halfDay ? 0.5 : 1.0;
}
$current = $current->modify('+1 day');
}
}
return $total;
}
}
WorkingDayChecker keeps the logic simple — a day is a working day if it is neither a weekend nor in the holiday list:
// src/Calendar/WorkingDayChecker.php
class WorkingDayChecker
{
public function isWorkingDay(DateTimeImmutable $date, array $holidays): bool
{
$dayOfWeek = (int) $date->format('N'); // ISO: 6=Sat, 7=Sun
if ($dayOfWeek >= 6) return false;
$formatted = $date->format('d.m.Y');
return !in_array($formatted, $holidays, true);
}
}
Holidays come from a free public API. GermanHolidayProvider fetches them per state using a locale string like de-BE for Berlin or de-BW for Baden-Württemberg:
// src/Calendar/GermanHolidayProvider.php
class GermanHolidayProvider implements HolidayProvider
{
public function getHolidays(int $year, string $locale): array
{
$state = strtoupper(explode('-', $locale)[1] ?? '');
$url = "https://feiertage-api.de/api/?jahr={$year}&nur_land={$state}";
$data = json_decode(file_get_contents($url), true);
return array_map(
fn($h) => DateTimeImmutable::createFromFormat('Y-m-d', $h['datum'])
->format('d.m.Y'),
$data
);
}
}
The final calculation step
Once we have earned days and used days, PtoCalculator applies the policy rules:
// src/Calculator/PtoCalculator.php
class PtoCalculator
{
public function calculate(
float $earned,
float $used,
PtoPolicy $policy,
bool $endOfYear = false
): float {
$remaining = $earned - $used;
if (!$policy->allowNegative) {
$remaining = max(0, $remaining);
}
if ($endOfYear && $policy->carryOverMax > 0) {
$remaining = min($remaining, $policy->carryOverMax);
}
return round($remaining, 1);
}
}
Clean and predictable. The carry-over cap only applies at year-end — during the year you can accumulate as much as you have earned.
Input validation with Symfony Validator
Raw CLI flags arrive as strings. Before building any domain objects they are mapped into a PtoInput DTO and validated with Symfony Validator attributes:
// src/PtoInput.php
class PtoInput
{
#[NotBlank, Range(min: 2020, max: 2100)]
public int $year;
#[NotBlank, Regex('/^\d{2}\.\d{2}\.\d{4}$/')]
public string $start;
#[Range(min: 0.1, max: 1.0)]
public float $workload = 1.0;
#[Range(min: 1, max: 365)]
public float $annualPto = 30;
#[Choice(choices: ['monthly', 'yearly'])]
public string $accrual = 'monthly';
/** @var string[] */
public array $pto = [];
#[Regex('/^de-[A-Z]{2}$/')]
public ?string $holiday = null;
#[Range(min: 0)]
public int $carryOverMax = 0;
public bool $allowNegative = false;
public bool $endOfYear = false;
}
If validation fails, the command prints all errors and exits without running any calculation:
$errors = $this->validator->validate($input);
if (count($errors) > 0) {
foreach ($errors as $error) {
$output->writeln("<error>{$error}</error>");
}
return Command::FAILURE;
}
Testing the whole pipeline
Every layer has its own unit tests. Here is a sample from PtoCalculatorTest that shows how the carry-over cap works:
public function testCarryOverCapAtYearEnd(): void
{
$policy = new PtoPolicy(
annualPto: 30,
accrualPolicy: new MonthlyAccrualPolicy(),
carryOverMax: 5,
allowNegative: false
);
// Earned 30, used 5 — balance of 25 is capped to 5 at year-end
$result = $this->calculator->calculate(30, 5, $policy, endOfYear: true);
$this->assertSame(5.0, $result);
}
And from PtoUsageCalculatorTest, verifying that a public holiday inside a range is not counted:
public function testSkipsPublicHoliday(): void
{
// Mon 12.05.2025 to Fri 16.05.2025 — 5 weekdays
// But 13.05.2025 (Ascension Day in Berlin) is a holiday
$requests = [new PtoRequest(
new DateTimeImmutable('2025-05-12'),
new DateTimeImmutable('2025-05-16')
)];
$holidays = ['13.05.2025'];
$result = $this->calculator->calculate($requests, $holidays);
$this->assertSame(4.0, $result);
}
Running the full suite:
php vendor/bin/phpunit
What I would change next
A few things I would revisit with more time:
- More holiday providers. The architecture already has a
HolidayProviderinterface — adding Austria or the Netherlands is a one-class addition. - Output formats. A
--format=jsonflag would make it easy to pipe results into other tools. - Config file support. Passing ten flags gets tedious. A
.pto.jsonfile in the project root would be a nicer experience for repeated use.
The project is open source at github.com/tapasdatta/pto-cli. Contributions welcome.
