vesselind/working-days-calculator

PHP library for calculating Bulgarian working days and legal deadlines

Maintainers

Package info

github.com/vesselind/working-days-calculator

pkg:composer/vesselind/working-days-calculator

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.1 2026-05-21 14:07 UTC

This package is auto-updated.

Last update: 2026-05-21 17:16:02 UTC


README

PHP ^8.2 License: MIT

A standalone, framework-agnostic PHP library for calculating Bulgarian working days, working hours, public holidays, and legal deadlines per the Bulgarian Labor Code (art. 154) and Civil Procedural Code (art. 60).

Installation

Via Packagist

composer require vesselind/working-days-calculator

Via local path repository

# In your project's composer.json, add the path repository:
composer config repositories.working-days-calculator path /path/to/WorkingDaysCalculator

# Then require the package:
composer require vesselind/working-days-calculator

Quick Start (≤ 5 lines — SC-004)

$result = (new \Vesselind\WorkingDaysCalculator\Calculator\WorkingDaysCalculator())->workingDaysInMonth(2026, 5);
echo $result->workingDays; // 18

Usage Examples

Working Days in a Month

use Vesselind\WorkingDaysCalculator\Calculator\WorkingDaysCalculator;

$calculator = new WorkingDaysCalculator();
$result = $calculator->workingDaysInMonth(2026, 5); // May 2026
echo $result->workingDays; // 18

$result->totalCalendarDays;  // 31 (May has 31 days)
$result->weekendDays;        // int — Saturdays + Sundays
$result->publicHolidayDays;  // int — all non-weekend holidays in May
$result->holidays;           // Holiday[] — each holiday with date, name, type

Working Days in a Full Year

$result = $calculator->workingDaysInYear(2025);
echo $result->workingDays; // e.g., 248

Working Days for a Custom Date Range

use Vesselind\WorkingDaysCalculator\Model\WorkPeriod;
use Carbon\CarbonImmutable;

$period = new WorkPeriod(
    startDate: new CarbonImmutable('2025-12-15'),
    endDate:   new CarbonImmutable('2026-01-15'),
);

$result = $calculator->workingDaysInPeriod($period);
echo $result->workingDays;

Working Hours

// Hours in a month
$hours = $calculator->workingHoursInMonth(2025, 1);   // January 2025
echo $hours->totalWorkingHours;  // workingDays × 8

// Hours in a year
$hours = $calculator->workingHoursInYear(2025);
echo $hours->totalWorkingHours;

// Hours in a custom period
$hours = $calculator->workingHoursInPeriod($period);
echo $hours->workingDays;
echo $hours->hoursPerDay;   // 8 (default)

Listing Holidays for a Period

use Vesselind\WorkingDaysCalculator\Model\HolidayType;

$holidays = $calculator->holidaysInPeriod($period); // Holiday[]

foreach ($holidays as $holiday) {
    echo $holiday->date->toDateString(); // e.g., 2025-01-01
    echo $holiday->name;                 // Нова година
    echo $holiday->type->value;          // 'fixed' | 'compensation' | 'orthodox_easter' | 'government_announced'
}

// Filter: only Easter holidays
$easterHolidays = array_filter(
    $holidays,
    fn($h) => $h->type === HolidayType::OrthodoxEaster
);

Injecting Government-Announced Days Off

use Carbon\CarbonImmutable;

$calculator = new WorkingDaysCalculator(
    governmentDaysOff: [
        new CarbonImmutable('2026-01-02'), // Bridge day announced by decree
    ]
);

$result = $calculator->workingDaysInMonth(2026, 1);
// January 2026 working days decreased by 1 for the injected day

// Safe to inject a date that is already a public holiday:
// it is deduplicated automatically — no double-counting.

Orthodox Easter Date

use Vesselind\WorkingDaysCalculator\Calculator\EasterCalculator;
use Carbon\CarbonImmutable;

$easter = new EasterCalculator();
$easterSunday = $easter->forYear(2025);   // CarbonImmutable: 2025-04-20

$goodFriday   = $easterSunday->subDays(2);  // 2025-04-18
$holySaturday = $easterSunday->subDays(1);  // 2025-04-19
$easterMonday = $easterSunday->addDays(1);  // 2025-04-21

Legal Deadline Calculation (CPC Art. 60)

use Vesselind\WorkingDaysCalculator\Calculator\DeadlineCalculator;
use Vesselind\WorkingDaysCalculator\Model\TermUnit;
use Carbon\CarbonImmutable;

$deadlineCalc = new DeadlineCalculator(new WorkingDaysCalculator());
$startDate    = new CarbonImmutable('2025-01-01');

// 1-month term
$deadline = $deadlineCalc->calculate($startDate, TermUnit::Month, 1);
echo $deadline->rawExpiry->toDateString();       // 2025-02-01
echo $deadline->resolvedExpiry->toDateString();  // next working day if 2025-02-01 is non-working

// 30-day term (counts from day after start date)
$deadline = $deadlineCalc->calculate($startDate, TermUnit::Day, 30);
echo $deadline->resolvedExpiry->toDateString();

// 1-year term from leap day
$deadline = $deadlineCalc->calculate(
    new CarbonImmutable('2024-02-29'), // leap year
    TermUnit::Year,
    1
);
echo $deadline->rawExpiry->toDateString();  // 2025-02-28 (Feb has no 29th in 2025)

Handling Invalid Input

use Vesselind\WorkingDaysCalculator\Exception\InvalidDateRangeException;
use Vesselind\WorkingDaysCalculator\Model\WorkPeriod;
use Carbon\CarbonImmutable;

try {
    $period = new WorkPeriod(
        startDate: new CarbonImmutable('2025-05-31'),
        endDate:   new CarbonImmutable('2025-05-01'), // end < start
    );
} catch (InvalidDateRangeException $e) {
    echo $e->getMessage();
    // "End date 2025-05-01 must be on or after start date 2025-05-31"
}

Legal Deadline Calculation (ЗЗД Art. 72 — Law of Obligations)

use Vesselind\WorkingDaysCalculator\Calculator\ZzdDeadlineCalculator;
use Vesselind\WorkingDaysCalculator\Calculator\WorkingDaysCalculator;
use Vesselind\WorkingDaysCalculator\Model\MonthAnchor;
use Vesselind\WorkingDaysCalculator\Model\TermUnit;
use Carbon\CarbonImmutable;

$calc = new ZzdDeadlineCalculator(new WorkingDaysCalculator());

// Forward term — 1 month (start day is NOT counted per art. 72 §2)
$deadline = $calc->calculate(new CarbonImmutable('2025-01-31'), TermUnit::Month, 1);
echo $deadline->rawExpiry->toDateString();       // 2025-02-28 (Feb has no 31st)
echo $deadline->resolvedExpiry->toDateString();  // first following working day if non-working

// Forward term — 30 days
$deadline = $calc->calculate(new CarbonImmutable('2025-01-01'), TermUnit::Day, 30);
echo $deadline->rawExpiry->toDateString();       // 2025-01-31

// Forward term — 2 weeks (expires on same weekday)
$deadline = $calc->calculate(new CarbonImmutable('2025-01-01'), TermUnit::Week, 2);
echo $deadline->rawExpiry->toDateString();       // 2025-01-15 (same weekday, 2 weeks later)

// Backward term — art. 72 §3: 3 days BEFORE a known date
// Neither the target day nor the expiry day is counted
$deadline = $calc->calculateBackward(new CarbonImmutable('2026-01-10'), 3);
echo $deadline->rawExpiry->toDateString();       // 2026-01-06 (Jan 7, 8, 9 = 3 days strictly between)
echo $deadline->resolvedExpiry->toDateString();  // advanced if non-working

// Month anchors — art. 72 §5
$date = new CarbonImmutable('2025-02-10');
echo $calc->resolveMonthAnchor($date, MonthAnchor::Beginning)->toDateString(); // 2025-02-01
echo $calc->resolveMonthAnchor($date, MonthAnchor::Middle)->toDateString();    // 2025-02-15
echo $calc->resolveMonthAnchor($date, MonthAnchor::End)->toDateString();       // 2025-02-28

ЗЗД art. 72 rules applied:

  • §1 Month terms clamp to last day of month when the target month is shorter.
  • §1 Week terms expire on the same weekday N weeks later.
  • §2 Day terms: the triggering event day is not counted.
  • §2 Non-working last day → first following working day (присъствен ден).
  • §3 Backward day terms: neither the target day nor the expiry day is counted.
  • §5 Named anchors: начало = 1st, среда = 15th, край = last day of month.

Requirements

Requirement Value
PHP version ^8.2
Carbon ^3.0 (nesbot/carbon)
Framework None (zero runtime framework dependencies)
Installation Composer
Namespace Vesselind\WorkingDaysCalculator\

Features

  • Bulgarian public holidays — Labor Code art. 154 (11 fixed holidays)
  • Compensation days — art. 154 §2 weekend-shift rule
  • Orthodox Easter — Meeus/Julian algorithm (2000–2100)
  • Government-announced days — art. 154 §3 (constructor injection, no global state)
  • Working hours — configurable hours-per-day multiplier
  • Legal deadlines (CPC) — CPC art. 60 (Day/Week/Month/Year terms with automatic advancement)
  • Legal deadlines (ЗЗД) — Law of Obligations art. 72: forward terms, backward day terms (§3, both endpoints excluded), named month anchors (§5 — beginning/middle/end of month)
  • Immutable value objects — all results are readonly DTOs
  • Strict typesdeclare(strict_types=1) in every file

License

MIT