Playing with PHP 8.6 Duration class


Playing with PHP 8.6 Duration class

So when I was reading what will be added in PHP8.6, I noticed they added the Duration class. I saw potential of native support in the Apie library for this class, so I could not wait to test out this class and add support for it.

One important issue first: I wrote this article and did my experiment on august the 10th with the current PHP 8.6 development timeline. As of August 10, 2026, PHP 8.6 is still in pre-release development: PHP 8.6.0 Alpha 3 was released July 30, with Beta 1 planned for August 13.

What is the duration class?

The Duration class represents a duration with nanosecond precision.. The cool thing is that it's designed to not have a public constructor. That is a good design, because to me it makes very little sense when writing something like: new Duration(5): is this 5 seconds or 5 milliseconds?

A duration instance is created with a static create method:

use Time\Duration;
$duration = Duration::fromSeconds(5, 900_000_000); // 5.9 seconds
$duration = Duration::fromMilliseconds(5); // 5 milliseconds

It's design is not perfect to be honest. Why does fromSeconds have a second argument for the nanoseconds, but fromMilliseconds does not? And it is also weird I can not create a duration from a floating point. Looking at the current implementation, the value is represented internally using seconds, nanoseconds and a sign. The class is mutable, which surprised me somewhat. Given the existence of DateTimeImmutable and experiences with bugs with DateTime, I wonder whether an immutable duration type will eventually be useful as well.

Making a testcase

So I started with a simple test case without code changes that is skipped in PHP versions lower than 8.6:


namespace Apie\Tests\Core;

use Apie\Core\Context\ApieContext;
use Apie\Fixtures\TestHelpers\TestWithFaker;
use Apie\Fixtures\TestHelpers\TestWithOpenapiSchema;
use Apie\Serializer\Serializer;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RequiresPhp;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Time\Duration;

#[RequiresPhp('>=8.6')]
class Php86Test extends TestCase
{
    use TestWithFaker;
    use TestWithOpenapiSchema;

    #[Test]
    #[DataProvider('provideFromSeconds')]
    public function it_can_be_denormalized(mixed $expected, mixed $input): void
    {
        $serializer = Serializer::create();
        $actual = $serializer->denormalizeNewObject($input, Duration::class, new ApieContext());
        static::assertEquals($expected, $actual);
    }

    public static function provideFromSeconds(): array
    {
        return [
            [Duration::fromMilliseconds(20), 20],
            [Duration::fromMilliseconds(20), 20.5],
        ];
    }

    #[Test]
    public function it_works_with_schema_generator()
    {
        $this->runOpenapiSchemaTestForCreation(
            Duration::class,
            'Duration-post',
            [
                'type' => 'number'
            ]
        );
    }

    #[\PHPUnit\Framework\Attributes\Test]
    public function it_works_with_apie_faker()
    {
        $this->runFakerTest(Duration::class);
    }
}
So I ran the release candidate docker image for php 8.6 to be wildly surprised:
Class Time\Duration not found!
It seems that this class was merged 7 days ago to the master branch, while the latest Docker image was created 8 days ago! It seems I was a bit too fast with testing.

So the only way to make a PHP8.6 docker image with the latest changes was making a docker image compile PHP from master:

FROM debian:trixie

RUN apt-get update && apt-get install -y \
    autoconf \
    build-essential \
    bison \
    pkg-config \
    re2c \
    libxml2-dev \
    libsqlite3-dev \
    libcurl4-openssl-dev \
    libonig-dev \
    libzip-dev \
    git \
 && git clone --depth=1 https://github.com/php/php-src.git /usr/src/php \
 && cd /usr/src/php \
 && ./buildconf \
 && ./configure --enable-mbstring --with-openssl \
 && make -j"$(nproc)" \
 && make install

CMD ["php", "-v"]

The most important part was to include mbstring as phpunit requires this. And as expected: all the tests fail, because my library can not handle objects with a private constructor out of the box.

json_encode a duration

While some people already have made simple polyfills for adding support for the Duration class in older PHP versions, I wanted to know how it behaves in actual PHP 8.6. For example: what happens if I use json_encode on it:


I was expecting a floating point number or an integer. If you want the floating point 1,5 to be converted into Duration::fromSeconds(1, 500_000_000); you have to do this yourself.

I used the structure given by json_encode for apie/serializer into serializing a Duration object to JSON. In a POST or PUT request body I do allow support for denormalizing a number into milliseconds or follow the internal structure currently used by PHP 8.6. If PHP 8.6 changes it, my testcase will fail here, which is intended as it is still very early to know if this becomes the used structure:

namespace Apie\Serializer\Normalizers;

use Apie\Core\Lists\ItemHashmap;
use Apie\Core\Lists\ItemList;
use Apie\Serializer\Context\ApieSerializerContext;
use Apie\Serializer\Interfaces\NormalizerInterface;
use Apie\TypeConverter\ReflectionTypeFactory;
use Psr\Http\Message\UploadedFileInterface;
use Time\Duration;

class DurationNormalizer implements NormalizerInterface
{
    public function supportsNormalization(mixed $object, ApieSerializerContext $apieSerializerContext): bool
    {
        return $object instanceof Duration;
    }

    public function supportsDenormalization(string|int|float|bool|null|ItemList|ItemHashmap|UploadedFileInterface $object, string $desiredType, ApieSerializerContext $apieSerializerContext): bool
    {
        return $desiredType === Duration::class;
    }

    public function normalize(mixed $object, ApieSerializerContext $apieSerializerContext): ItemHashmap
    {
        return $apieSerializerContext->normalizeAgain(json_decode(json_encode($object), true));
    }
}

For example for apie/faker this became my faker class to add support for Duration:

namespace Apie\Faker\Fakers;

use Apie\Faker\Interfaces\ApieClassFaker;
use Faker\Generator;
use ReflectionClass;
use Time\Duration;

/** @implements ApieClassFaker<Duration> */
class DurationFaker implements ApieClassFaker
{
    public function supports(ReflectionClass $class): bool
    {
        return $class->name === Duration::class;
    }

    public function fakeFor(Generator $generator, ReflectionClass $class): Duration
    {
        return Duration::fromSeconds($generator->numberBetween(0, 1000), $generator->numberBetween(0, 999999999));
    }
}

The modular structure (and a bit Symfony-like structure with the supports metho) made it relatively easy to add support.

Conclusion

I think it's a bit early to adapt to PHP 8.6. It's still 3 months away. It's good to run your library with the latest PHP version to see what deprecated features you are using in your library that requires changes in the future (so you could run it in the CI where this build is allowed to fail).

As for Duration: it does what it should do and my Apie library should support it natively. I do find the internal datastructure with seconds and nanoseconds and a negative boolean a bit odd but it does what it should do. The internal error control is also greatly appreciated. I could not enter invalid data.

I do wonder if the json_encode behaviour is specified or intentional behaviour, but again it works.


Comments