Every PHP team eventually ships a billing bug that only reproduces twice a year. Mine was an invoice that landed on the wrong day for users in Santiago: the job ran at 00:30 local time, the server stored UTC, Chile shifted its offset, and strtotime('+1 day') cheerfully produced a timestamp 23 hours later. The fix was not a cleverer string function — it was replacing the ad-hoc date handling with a library that models time zones as first-class data.

Choosing that library in 2026 comes down to three real contenders: Carbon (16,585 GitHub stars), Chronos (1,362 stars) and Brick\DateTime (361 stars). All three are actively maintained — Carbon, Chronos and Brick all pushed commits within the last three weeks of October 2026 — and all three are MIT licensed, so license politics will not save you from making a decision.

Here is the decision, made for you, plus the code and the traps that the README files leave out.

TL;DR: The 10-Second Verdict

Use Carbon if you are building an application — it has the widest API surface, first-class locale support, human-readable diffs (“3 hours ago”), test-time freezing, and every Laravel/Symfony tutorial assumes it. Use Chronos if you want immutability by default and a CakePHP-grade, dependency-light core — its ChronosDate/ChronosTime split in the 3.x line is the cleanest domain-date model of the three. Use Brick\DateTime if you are writing a library, a financial system, or anything that must survive an audit — zero runtime dependencies, strict types, explicit value objects, and no magic method that silently mutates a shared instance.

If you only remember one rule: never let mutable date objects cross a service boundary. Carbon’s default object is mutable; that single fact causes more production surprises than every timezone bug combined.

How the Three Libraries Compare

DimensionCarbon 3.xChronos 3.xBrick\DateTime 0.8
GitHub stars16,5851,362361
Last push2026-10-312026-10-212026-10-10
LicenseMITMITMIT
Default mutabilityMutable (Carbon), immutable variant availableImmutableImmutable (only option)
PHP support8.1+8.1+8.0+, 64-bit required
Runtime dependenciessymfony/translation-contracts, psr/clock, doctrine typesNone beyond psr/clockNone
Locale-aware formattingYes, via ->locale() and translationsMinimalNo (formatting only)
Human-readable diffsYes (diffForHumans())NoNo
Separate date / time / duration typesNo (all Carbon objects)Yes (ChronosDate, ChronosTime, ChronosPeriod)Yes (LocalDate, LocalTime, Duration, Instant, ZonedDateTime)
Test clock freezingCarbon::setTestNow()Chronos::setTestNow()Inject Clock
DST-safe arithmeticYesYesYes, and explicit
Learning curveLowestMediumHighest

Two rows deserve emphasis. First, dependencies: Carbon pulls in translation contracts even if you never localize, which matters in Lambda-style deployments where every autoloaded file counts against cold-start. Second, type separation: Brick and Chronos can express “a date with no time and no zone” natively. Carbon cannot — a Carbon instance is always a date-time with a zone, which is exactly the abstraction leak that turns an invoice date into a timestamp at midnight UTC and shifts it a day for half the planet.

Decision Matrix: Pick by Use Case

Your situationRecommendedWhy
Laravel or Symfony app with heavy UI date formattingCarbondiffForHumans(), locale bundles, and Blade helpers already assume it
Multi-tenant SaaS with billing periodsChronosImmutable by default; ChronosDate prevents time-of-day contamination
Financial ledger, audit trail, or public libraryBrick\DateTimeZero dependencies, strict value objects, no hidden mutation
Legacy codebase migrating off DateTimeCarbonClosest API surface, Carbon::instance() converts in one call
Date-only domain models (holidays, schedules, birthdays)Chronos or BrickBoth have a real date type; Carbon does not
You need Persian, Arabic, or Chinese month namesCarbonBundled translations; the others require custom formatters
You need maximum control over DST gaps and overlapsBrick\DateTimeExplicit ZonedDateTime semantics, no silent normalization you did not ask for

Carbon: The Batteries-Included Workhorse

Carbon’s pitch is simple: it is DateTime with a human API. Installation is a single Composer line, and the object behaves like the native class it extends, so existing code keeps working while you upgrade incrementally.

1
composer require nesbot/carbon
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php
use Carbon\CarbonImmutable;
use Carbon\CarbonPeriod;

// Parsing with an explicit zone — always pass the zone, never rely on date_default_timezone_get()
$cutoff = CarbonImmutable::parse('2026-03-08 01:30:00', 'America/New_York');

// Spring-forward arithmetic: 02:30 does not exist on this date in New York
$billing = $cutoff->addHours(1);
echo $billing->toIso8601String(), PHP_EOL;   // 2026-03-08T03:30:00-04:00

// Locale-aware output without touching global state
echo CarbonImmutable::now('Europe/Berlin')->locale('de')->isoFormat('dddd, D. MMMM YYYY'), PHP_EOL;

// Periods replace hand-rolled while loops
foreach (CarbonPeriod::create('2026-11-01', '1 week', '2026-12-01') as $day) {
    echo $day->format('D, M j'), PHP_EOL;
}

Three things make Carbon pleasant in practice. CarbonImmutable gives you value semantics without leaving the family — the setters return new instances, so $a->addDay() never surprises a caller holding $a. CarbonPeriod removes the off-by-one bugs that show up in every hand-written date-range loop. And Carbon::setTestNow() (or CarbonImmutable::setTestNow()) lets the whole test suite travel in time with one line, which is the single best reason to keep Carbon even if you dislike its mutable default.

The cost: Carbon is generous with magic. __get proxies, dynamic property access, and the mutable default mean static analysis tools see less than you would like, and a careless use Carbon\Carbon; in a long-lived service object is a latent bug.

Chronos: Immutability First, CakePHP Roots

Chronos began as CakePHP’s date layer and is now standalone. Its 3.x line makes the immutability decision for you and, more importantly, splits the domain into three explicit types.

1
composer require cakephp/chronos
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
use Cake\Chronos\Chronos;
use Cake\Chronos\ChronosDate;
use Cake\Chronos\ChronosPeriod;

// Immutable date-time: every modifier returns a new instance
$opened = Chronos::parse('2026-11-01 09:00:00', 'Europe/London');
$due    = $opened->addWeekdays(3)->setTime(17, 0);
echo $due->toIso8601String(), PHP_EOL;

// A real date type — no time, no zone, no accidental midnight conversion
$holiday = ChronosDate::parse('2026-12-25');
echo $holiday->addDays(1)->toDateString(), PHP_EOL;   // 2026-12-26

// Month arithmetic that does not overflow: Jan 31 + 1 month = Feb 28
$statement = ChronosDate::parse('2027-01-31')->addMonthNoOverflow();
echo $statement->toDateString(), PHP_EOL;             // 2027-02-28

// Periods and humanized intervals without a translation dependency
$period = new ChronosPeriod([ChronosDate::parse('2026-11-01'), ChronosDate::parse('2026-11-15')]);
echo $period->getEndDate()->diffInDays($period->getStartDate()), ' days', PHP_EOL;

The addMonthNoOverflow() method alone justifies Chronos for billing code. PHP’s native month arithmetic turns January 31 into March 3, a bug that hides in production until someone runs a monthly close on the 31st. Chronos makes the overflow behaviour explicit instead of accidental.

Chronos also keeps the dependency graph almost flat: no translation bundles, no third-party contracts beyond the PSR clock interface. The trade-off is a smaller ecosystem — no diffForHumans(), no locale month names — so if your UI shows “vor 3 Stunden”, you either add a formatter or choose Carbon.

Brick\DateTime: The Strict, Zero-Dependency Option

Brick\DateTime is what a Java java.time developer would build in PHP: small, final, immutable value objects with no shared state and no extension of the native DateTime. It requires a 64-bit build, which is a fair trade for exact integer timestamp math.

1
composer require brick/date-time
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<?php
use Brick\DateTime\Duration;
use Brick\DateTime\Instant;
use Brick\DateTime\LocalDate;
use Brick\DateTime\LocalDateTime;
use Brick\DateTime\LocalTime;
use Brick\DateTime\TimeZone;

$date = LocalDate::of(2026, 11, 1);
$time = LocalTime::of(14, 30);

// Explicit composition: no implicit zone, no hidden conversion
$local    = LocalDateTime::of($date->getYear(), $date->getMonth(), $date->getDayOfMonth(), $time->getHour(), $time->getMinute());
$zoned    = $local->atTimeZone(TimeZone::parse('Europe/Paris'));
$instant  = $zoned->getInstant();
$deadline = $instant->plus(Duration::ofMinutes(90));

echo $deadline->toString(), PHP_EOL;          // 2026-11-01T14:00:30Z-style instant
echo $date->plusMonths(1)->toString(), PHP_EOL; // 2026-12-01, clamped, never overflowing

The verbosity is the feature. When a LocalDate can never carry a zone, a midnight-UTC conversion bug becomes a type error instead of a support ticket. Brick also clamps month arithmetic by default rather than overflowing, matches ISO-8601 parsing rules strictly, and exposes Instant, Duration and Period as separate concepts, which is exactly what you want when modelling loan schedules or retention windows.

It is also the least forgiving option for a team raised on strtotime(). There is no diffForHumans(), no locale formatting, and every conversion is explicit. Budget a day for the first service you convert, and expect your code to get longer before it gets better.

Where Each Library Breaks: Pitfalls and Migration Traps

The mutable-default trap. Carbon::now() returns a mutable object. Pass it to a helper that calls addDay(), and the caller’s variable changes. Fix: import CarbonImmutable everywhere and ban Carbon\Carbon in new code with a static-analysis rule. This is a one-line change that eliminates an entire bug class.

date_default_timezone_get() is not a timezone strategy. All three libraries accept a zone argument; none of them can guess it correctly for a user in another country. Store UTC in the database, store the user’s IANA zone alongside it, and convert at the edges.

DST gaps and overlaps. On 2026-03-08 in America/New_York, 02:30 does not exist; on 2026-11-01, 01:30 happens twice. Carbon and Chronos normalize silently, Brick makes you choose an offset resolution. For anything that bills by the hour, decide explicitly whether a 23-hour day bills as 23 or 24 hours — the library will not decide for you.

Month-end overflow. 2027-01-31 + 1 month is 2027-03-03 with native PHP arithmetic. Use addMonthNoOverflow() in Chronos, or Brick’s clamping plusMonths(), and never assume Carbon’s addMonth() is safe for statement dates.

Serialization drift. Carbon serializes to ISO-8601 with offset by default; Chronos and Brick follow their own contracts. If dates go into JSON APIs consumed by mobile clients, pin the format explicitly in your DTO layer instead of relying on json_encode() defaults.

Mixing families in one codebase. Converting between them is possible (Carbon::instance(), Chronos::instance()) but every conversion is a place where a zone gets dropped. Pick one library for domain logic and convert only at the boundary.

If you are evaluating the surrounding stack too, our PHP validation library comparison and the PHP HTTP client guide follow the same format, and the .NET date-time library comparison covers the same problem from the other side of the fence.

FAQ

Is Carbon faster than Chronos?

Parsing and arithmetic speed is close enough that it will not decide your architecture — differences are in the low microseconds per operation and are dominated by autoloading and timezone database lookups. Carbon’s larger dependency graph costs more at cold start; Chronos and Brick load fewer files. Benchmark against your own workload rather than trusting microbenchmarks, and cache the timezone database if you create many zoned objects per request.

Can I use Carbon and Chronos in the same project?

Yes, and many legacy CakePHP codebases do. Chronos::instance($carbon) and Carbon::instance($chronos) convert cleanly because both wrap the same underlying timestamp. The risk is conceptual, not technical: two date types in one domain layer guarantee that someone eventually passes a mutable Carbon into an immutable pipeline.

Does Chronos really prevent the January 31 bug?

Only if you call addMonthNoOverflow(). Plain addMonth() overflows in Chronos exactly as it does in PHP’s native DateTime, because Chronos follows PHP semantics unless told otherwise. Make the no-overflow variant your default in billing code and grep for addMonth( during review.

Which library should I use for date-only values like birthdays or holidays?

Chronos’ ChronosDate or Brick’s LocalDate. Carbon has no date-only type — a Carbon is always a date-time with a zone, so a birthday stored as Carbon can render as the previous day for users west of UTC. This single mismatch causes a surprising number of “wrong day” bug reports.

Do these libraries handle historical timezone changes?

All three rely on the system or bundled timezone database (typically the tzdata package or PHP’s compiled-in version). Historical offsets are generally correct, but the database must be updated when governments change rules — Chile, Egypt and Morocco have all shifted rules in the last few years. Check your container image’s tzdata version during deploys.


💰 想测试你的市场判断力?我用 Polymarket 做预测市场交易——这是全球最大的预测市场平台,从大选结果到技术监管时间线,什么都可以押注。和赌博不同,这是真正的信息市场:你懂的信息越多,胜率越高。我靠预测技术相关事件的走向已经赚了不少。用我的邀请链接注册:Polymarket.com