diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..a4a3fb8
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,13 @@
+/tests export-ignore
+/vendor export-ignore
+
+/README.md export-ignore
+/LICENSE export-ignore
+/Makefile export-ignore
+/phpunit.xml export-ignore
+/phpstan.neon.dist export-ignore
+/infection.json.dist export-ignore
+
+/.github export-ignore
+/.gitignore export-ignore
+/.gitattributes export-ignore
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..f0ce8fc
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,31 @@
+version: 2
+
+updates:
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 0
+ labels:
+ - "php"
+ - "security"
+ - "dependencies"
+ groups:
+ php-security:
+ applies-to: security-updates
+ patterns:
+ - "*"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ commit-message:
+ prefix: "build"
+ labels:
+ - "dependencies"
+ - "github-actions"
+ groups:
+ github-actions:
+ patterns:
+ - "*"
diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml
new file mode 100644
index 0000000..d0ba49e
--- /dev/null
+++ b/.github/workflows/auto-assign.yml
@@ -0,0 +1,25 @@
+name: Auto assign issues and pull requests
+
+on:
+ issues:
+ types:
+ - opened
+ pull_request:
+ types:
+ - opened
+
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ pull-requests: write
+ steps:
+ - name: Assign issues and pull requests
+ uses: gustavofreze/auto-assign@2.1.0
+ with:
+ assignees: '${{ vars.ASSIGNEES }}'
+ github_token: '${{ secrets.GITHUB_TOKEN }}'
+ allow_self_assign: 'true'
+ allow_no_assignees: 'true'
+ assignment_options: 'ISSUE,PULL_REQUEST'
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6be2ffc
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,87 @@
+name: CI
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+env:
+ PHP_VERSION: '8.5'
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Configure PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ tools: composer:2
+
+ - name: Validate composer.json
+ run: composer validate --no-interaction
+
+ - name: Install dependencies
+ run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction
+
+ - name: Upload vendor and composer.lock as artifact
+ uses: actions/upload-artifact@v6
+ with:
+ name: vendor-artifact
+ path: |
+ vendor
+ composer.lock
+
+ auto-review:
+ name: Auto review
+ runs-on: ubuntu-latest
+ needs: build
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Configure PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ tools: composer:2
+
+ - name: Download vendor artifact from build
+ uses: actions/download-artifact@v7
+ with:
+ name: vendor-artifact
+ path: .
+
+ - name: Run review
+ run: composer review
+
+ tests:
+ name: Tests
+ runs-on: ubuntu-latest
+ needs: auto-review
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Configure PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ tools: composer:2
+
+ - name: Download vendor artifact from build
+ uses: actions/download-artifact@v7
+ with:
+ name: vendor-artifact
+ path: .
+
+ - name: Run tests
+ run: composer tests
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..4c6d7f7
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,35 @@
+name: Security checks
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+ schedule:
+ - cron: "0 0 * * *"
+
+permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ "actions" ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Perform CodeQL analysis
+ uses: github/codeql-action/analyze@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..42b841a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.idea
+
+vendor
+report
+.phpunit.*
+
+*.lock
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ef9a884
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,68 @@
+PWD := $(CURDIR)
+ARCH := $(shell uname -m)
+PLATFORM :=
+
+ifeq ($(ARCH),arm64)
+ PLATFORM := --platform=linux/amd64
+endif
+
+DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine
+
+RESET := \033[0m
+GREEN := \033[0;32m
+YELLOW := \033[0;33m
+
+.DEFAULT_GOAL := help
+
+.PHONY: configure
+configure: ## Configure development environment
+ @${DOCKER_RUN} composer update --optimize-autoloader
+
+.PHONY: test
+test: ## Run all tests with coverage
+ @${DOCKER_RUN} composer tests
+
+.PHONY: test-file
+test-file: ## Run tests for a specific file (usage: make test-file FILE=path/to/file)
+ @${DOCKER_RUN} composer test-file ${FILE}
+
+.PHONY: test-no-coverage
+test-no-coverage: ## Run all tests without coverage
+ @${DOCKER_RUN} composer tests-no-coverage
+
+.PHONY: review
+review: ## Run static code analysis
+ @${DOCKER_RUN} composer review
+
+.PHONY: show-reports
+show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser
+ @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html
+
+.PHONY: clean
+clean: ## Remove dependencies and generated artifacts
+ @sudo chown -R ${USER}:${USER} ${PWD}
+ @rm -rf report vendor .phpunit.cache *.lock
+
+.PHONY: help
+help: ## Display this help message
+ @echo "Usage: make [target]"
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')"
+ @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')"
+ @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')"
+ @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')"
+ @grep -E '^(show-reports):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')"
+ @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
diff --git a/README.md b/README.md
index 119c7f2..3cf378e 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,214 @@
-# time
-Immutable and strict time Value Objects for safe parsing, formatting and normalization.
+# Time
+
+[](LICENSE)
+
+* [Overview](#overview)
+* [Installation](#installation)
+* [How to use](#how-to-use)
+ * [Instant](#instant)
+ * [Timezone](#timezone)
+ * [Timezones](#timezones)
+* [License](#license)
+* [Contributing](#contributing)
+
+
+
+## Overview
+
+Value Object representing time in an immutable and strict way, focused on safe parsing, formatting and normalization.
+
+
+## Installation
+
+```bash
+composer require tiny-blocks/time
+```
+
+
+
+## How to use
+
+The library provides immutable Value Objects for representing points in time and IANA timezones. All instants are
+normalized to UTC internally.
+
+### Instant
+
+An `Instant` represents a single point on the timeline, always stored in UTC with microsecond precision.
+
+#### Creating from a string
+
+Parses a date-time string with an explicit UTC offset. The value is normalized to UTC regardless of the original offset.
+
+```php
+use TinyBlocks\Time\Instant;
+
+$instant = Instant::fromString(value: '2026-02-17T13:30:00-03:00');
+
+$instant->toIso8601(); # 2026-02-17T16:30:00+00:00
+$instant->toUnixSeconds(); # 1771345800
+$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC)
+```
+
+#### Creating from Unix seconds
+
+Creates an `Instant` from a Unix timestamp in seconds.
+
+```php
+use TinyBlocks\Time\Instant;
+
+$instant = Instant::fromUnixSeconds(seconds: 0);
+
+$instant->toIso8601(); # 1970-01-01T00:00:00+00:00
+$instant->toUnixSeconds(); # 0
+```
+
+#### Creating from the current moment
+
+Captures the current moment with microsecond precision, normalized to UTC.
+
+```php
+use TinyBlocks\Time\Instant;
+
+$instant = Instant::now();
+
+$instant->toIso8601(); # 2026-02-17T10:30:00+00:00 (current UTC time)
+$instant->toUnixSeconds(); # 1771324200 (current Unix timestamp)
+$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC, with microseconds)
+```
+
+#### Formatting as ISO 8601
+
+The `toIso8601` method always returns the format `YYYY-MM-DDTHH:MM:SS+00:00`, without fractional seconds.
+
+```php
+use TinyBlocks\Time\Instant;
+
+$instant = Instant::fromString(value: '2026-02-17T19:30:00+09:00');
+
+$instant->toIso8601(); # 2026-02-17T10:30:00+00:00
+```
+
+#### Accessing the underlying DateTimeImmutable
+
+Returns a `DateTimeImmutable` in UTC with full microsecond precision.
+
+```php
+use TinyBlocks\Time\Instant;
+
+$instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00');
+$dateTime = $instant->toDateTimeImmutable();
+
+$dateTime->getTimezone()->getName(); # UTC
+$dateTime->format('Y-m-d\TH:i:s.u'); # 2026-02-17T10:30:00.000000
+```
+
+### Timezone
+
+A `Timezone` is a Value Object representing a single valid [IANA timezone](https://www.iana.org) identifier.
+
+#### Creating from an identifier
+
+```php
+use TinyBlocks\Time\Timezone;
+
+$timezone = Timezone::from(identifier: 'America/Sao_Paulo');
+
+$timezone->value; # America/Sao_Paulo
+$timezone->toString(); # America/Sao_Paulo
+```
+
+#### Creating a UTC timezone
+
+```php
+use TinyBlocks\Time\Timezone;
+
+$timezone = Timezone::utc();
+
+$timezone->value; # UTC
+```
+
+#### Converting to DateTimeZone
+
+```php
+use TinyBlocks\Time\Timezone;
+
+$timezone = Timezone::from(identifier: 'Asia/Tokyo');
+$dateTimeZone = $timezone->toDateTimeZone();
+
+$dateTimeZone->getName(); # Asia/Tokyo
+```
+
+### Timezones
+
+An immutable collection of `Timezone` objects.
+
+#### Creating from objects
+
+```php
+use TinyBlocks\Time\Timezone;
+use TinyBlocks\Time\Timezones;
+
+$timezones = Timezones::from(
+ Timezone::from(identifier: 'America/Sao_Paulo'),
+ Timezone::from(identifier: 'America/New_York'),
+ Timezone::from(identifier: 'Asia/Tokyo')
+);
+
+$timezones->count(); # 3
+```
+
+#### Creating from strings
+
+```php
+use TinyBlocks\Time\Timezones;
+
+$timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Europe/London');
+
+$timezones->count(); # 3
+$timezones->toStrings(); # ["UTC", "America/Sao_Paulo", "Europe/London"]
+```
+
+#### Getting all timezones
+
+Returns all `Timezone` objects in the collection:
+
+```php
+$timezones->all(); # [Timezone("UTC"), Timezone("America/Sao_Paulo"), Timezone("Europe/London")]
+```
+
+#### Finding a timezone by identifier
+
+Searches for a specific IANA identifier within the collection. Returns `null` if not found.
+
+```php
+use TinyBlocks\Time\Timezones;
+
+$timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Asia/Tokyo');
+
+$timezones->findByIdentifier(iana: 'Asia/Tokyo'); # Timezone("Asia/Tokyo")
+$timezones->findByIdentifier(iana: 'Europe/London'); # null
+```
+
+#### Checking if a timezone exists in the collection
+
+```php
+use TinyBlocks\Time\Timezones;
+
+$timezones = Timezones::fromStrings('America/Sao_Paulo', 'Asia/Tokyo');
+
+$timezones->contains(iana: 'Asia/Tokyo'); # true
+$timezones->contains(iana: 'America/New_York'); # false
+```
+
+
+
+## License
+
+Time is licensed under [MIT](LICENSE).
+
+
+
+## Contributing
+
+Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to
+contribute to the project.
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..b0b16c1
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,75 @@
+{
+ "name": "tiny-blocks/time",
+ "type": "library",
+ "license": "MIT",
+ "homepage": "https://github.com/tiny-blocks/time",
+ "description": "Value Object representing time in an immutable and strict way, focused on safe parsing, formatting and normalization.",
+ "prefer-stable": true,
+ "minimum-stability": "stable",
+ "keywords": [
+ "vo",
+ "psr",
+ "utc",
+ "time",
+ "iso-8601",
+ "datetime",
+ "immutable",
+ "tiny-blocks",
+ "value-object"
+ ],
+ "authors": [
+ {
+ "name": "Gustavo Freze de Araujo Santos",
+ "homepage": "https://github.com/gustavofreze"
+ }
+ ],
+ "support": {
+ "issues": "https://github.com/tiny-blocks/time/issues",
+ "source": "https://github.com/tiny-blocks/time"
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "infection/extension-installer": true
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "TinyBlocks\\Time\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Test\\TinyBlocks\\Time\\": "tests/"
+ }
+ },
+ "require": {
+ "php": "^8.5",
+ "tiny-blocks/value-object": "^3.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.5",
+ "phpstan/phpstan": "^2.1",
+ "infection/infection": "^0.32",
+ "squizlabs/php_codesniffer": "^4.0"
+ },
+ "scripts": {
+ "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests",
+ "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src",
+ "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress",
+ "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter",
+ "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage",
+ "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests",
+ "review": [
+ "@phpcs",
+ "@phpstan"
+ ],
+ "tests": [
+ "@test",
+ "@mutation-test"
+ ],
+ "tests-no-coverage": [
+ "@test-no-coverage"
+ ]
+ }
+}
diff --git a/infection.json.dist b/infection.json.dist
new file mode 100644
index 0000000..437290c
--- /dev/null
+++ b/infection.json.dist
@@ -0,0 +1,24 @@
+{
+ "logs": {
+ "text": "report/infection/logs/infection-text.log",
+ "summary": "report/infection/logs/infection-summary.log"
+ },
+ "tmpDir": "report/infection/",
+ "minMsi": 100,
+ "timeout": 30,
+ "source": {
+ "directories": [
+ "src"
+ ]
+ },
+ "phpUnit": {
+ "configDir": "",
+ "customPath": "./vendor/bin/phpunit"
+ },
+ "mutators": {
+ "@default": true,
+ "AssignCoalesce": false
+ },
+ "minCoveredMsi": 100,
+ "testFramework": "phpunit"
+}
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..dd23e27
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,8 @@
+parameters:
+ paths:
+ - src
+ level: 9
+ tmpDir: report/phpstan
+ ignoreErrors:
+ - '#Parameter#'
+ reportUnmatchedIgnoredErrors: false
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..40c80a2
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+ src
+
+
+
+
+
+ tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Instant.php b/src/Instant.php
new file mode 100644
index 0000000..009d6c2
--- /dev/null
+++ b/src/Instant.php
@@ -0,0 +1,105 @@
+toDateTimeZone();
+ $datetime = DateTimeImmutable::createFromFormat(
+ self::MICROSECOND_FORMAT,
+ sprintf('%.6F', microtime(true)),
+ $utc
+ );
+
+ /** @var DateTimeImmutable $datetime */
+ return new Instant(datetime: $datetime->setTimezone($utc));
+ }
+
+ /**
+ * Creates an Instant by decoding a date-time string.
+ *
+ * @param string $value A date-time string in a supported format (e.g. 2026-02-17T10:30:00+00:00).
+ * @return Instant The created Instant, normalized to UTC.
+ * @throws InvalidInstant If the value cannot be decoded into a valid instant.
+ */
+ public static function fromString(string $value): Instant
+ {
+ $decoder = TextDecoder::create();
+ $datetime = $decoder->decode(value: $value);
+
+ return new Instant(datetime: $datetime);
+ }
+
+ /**
+ * Creates an Instant from a Unix timestamp in seconds.
+ *
+ * @param int $seconds The number of seconds since the Unix epoch (1970-01-01T00:00:00Z).
+ * @return Instant The created Instant, normalized to UTC.
+ */
+ public static function fromUnixSeconds(int $seconds): Instant
+ {
+ $utc = Timezone::utc()->toDateTimeZone();
+ $datetime = DateTimeImmutable::createFromFormat(self::UNIX_FORMAT, (string)$seconds, $utc);
+
+ /** @var DateTimeImmutable $datetime */
+ return new Instant(datetime: $datetime->setTimezone($utc));
+ }
+
+ /**
+ * Formats this instant as an ISO 8601 string in UTC (e.g. 2026-02-17T10:30:00+00:00).
+ *
+ * @return string The ISO 8601 representation without fractional seconds.
+ */
+ public function toIso8601(): string
+ {
+ return $this->datetime->format(self::ISO8601_FORMAT);
+ }
+
+ /**
+ * Returns the number of seconds since the Unix epoch.
+ *
+ * @return int The Unix timestamp in seconds.
+ */
+ public function toUnixSeconds(): int
+ {
+ return $this->datetime->getTimestamp();
+ }
+
+ /**
+ * Returns the underlying DateTimeImmutable instance in UTC.
+ *
+ * @return DateTimeImmutable The UTC date-time with microsecond precision.
+ */
+ public function toDateTimeImmutable(): DateTimeImmutable
+ {
+ return $this->datetime;
+ }
+}
diff --git a/src/Internal/Decoders/Decoder.php b/src/Internal/Decoders/Decoder.php
new file mode 100644
index 0000000..f36e8f6
--- /dev/null
+++ b/src/Internal/Decoders/Decoder.php
@@ -0,0 +1,18 @@
+setTimezone(new DateTimeZone('UTC'));
+ }
+}
diff --git a/src/Internal/Exceptions/InvalidInstant.php b/src/Internal/Exceptions/InvalidInstant.php
new file mode 100644
index 0000000..91ef7be
--- /dev/null
+++ b/src/Internal/Exceptions/InvalidInstant.php
@@ -0,0 +1,17 @@
+ could not be decoded into a valid instant.';
+
+ parent::__construct(message: sprintf($template, $this->value));
+ }
+}
diff --git a/src/Internal/Exceptions/InvalidTimezone.php b/src/Internal/Exceptions/InvalidTimezone.php
new file mode 100644
index 0000000..2f3bba1
--- /dev/null
+++ b/src/Internal/Exceptions/InvalidTimezone.php
@@ -0,0 +1,17 @@
+ is invalid.';
+
+ parent::__construct(message: sprintf($template, $this->identifier));
+ }
+}
diff --git a/src/Internal/TextDecoder.php b/src/Internal/TextDecoder.php
new file mode 100644
index 0000000..b6ffc85
--- /dev/null
+++ b/src/Internal/TextDecoder.php
@@ -0,0 +1,42 @@
+decoders = $decoders;
+ }
+
+ public static function create(): self
+ {
+ return new TextDecoder(decoders: [new OffsetDateTimeDecoder()]);
+ }
+
+ public function decode(string $value): DateTimeImmutable
+ {
+ foreach ($this->decoders as $decoder) {
+ $result = $decoder->decode(value: $value);
+
+ if ($result !== null) {
+ return $result;
+ }
+ }
+
+ throw new InvalidInstant(value: $value);
+ }
+}
diff --git a/src/Timezone.php b/src/Timezone.php
new file mode 100644
index 0000000..5f68a78
--- /dev/null
+++ b/src/Timezone.php
@@ -0,0 +1,78 @@
+value = $identifier;
+ }
+
+ /**
+ * Creates a Timezone representing UTC.
+ *
+ * @return Timezone The UTC Timezone instance.
+ */
+ public static function utc(): Timezone
+ {
+ return new Timezone(identifier: 'UTC');
+ }
+
+ /**
+ * Creates a Timezone from a valid IANA identifier.
+ *
+ * @param string $identifier The IANA timezone identifier (e.g. America/Sao_Paulo).
+ * @return Timezone The created Timezone instance.
+ * @throws InvalidTimezone If the identifier is not a valid IANA timezone.
+ */
+ public static function from(string $identifier): Timezone
+ {
+ return new Timezone(identifier: $identifier);
+ }
+
+ /**
+ * Returns the IANA timezone identifier as a string.
+ *
+ * @return string The IANA timezone identifier.
+ */
+ public function toString(): string
+ {
+ return $this->value;
+ }
+
+ /**
+ * Converts this Timezone to a DateTimeZone instance.
+ *
+ * @return DateTimeZone The corresponding DateTimeZone.
+ */
+ public function toDateTimeZone(): DateTimeZone
+ {
+ return new DateTimeZone($this->value);
+ }
+
+ /**
+ * @return list
+ */
+ private static function allIdentifiers(): array
+ {
+ /** @var list|null $identifiers */
+ static $identifiers = null;
+
+ return $identifiers ??= DateTimeZone::listIdentifiers();
+ }
+}
diff --git a/src/Timezones.php b/src/Timezones.php
new file mode 100644
index 0000000..f0f0750
--- /dev/null
+++ b/src/Timezones.php
@@ -0,0 +1,114 @@
+ */
+ private array $items;
+
+ /**
+ * @param list $items
+ */
+ private function __construct(array $items)
+ {
+ $this->items = $items;
+ }
+
+ /**
+ * Creates a collection from Timezone objects.
+ *
+ * @param Timezone ...$timezones One or more Timezone instances.
+ * @return Timezones The created collection.
+ */
+ public static function from(Timezone ...$timezones): Timezones
+ {
+ return new Timezones(items: $timezones);
+ }
+
+ /**
+ * Creates a collection from IANA identifier strings.
+ *
+ * @param string ...$identifiers One or more IANA timezone identifiers (e.g. America/Sao_Paulo).
+ * @return Timezones The created collection.
+ * @throws InvalidTimezone If any identifier is not a valid IANA timezone.
+ */
+ public static function fromStrings(string ...$identifiers): Timezones
+ {
+ $items = array_map(
+ static fn(string $identifier): Timezone => Timezone::from(identifier: $identifier),
+ $identifiers
+ );
+
+ return new Timezones(items: $items);
+ }
+
+ /**
+ * Returns all Timezone objects in this collection.
+ *
+ * @return list The list of all Timezone objects.
+ */
+ public function all(): array
+ {
+ return $this->items;
+ }
+
+ /**
+ * Returns the number of timezones in this collection.
+ *
+ * @return int The total count.
+ */
+ public function count(): int
+ {
+ return count($this->items);
+ }
+
+ /**
+ * Checks whether the given IANA identifier exists in this collection.
+ *
+ * @param string $iana The IANA timezone identifier to check (e.g. America/New_York).
+ * @return bool True if the identifier exists in this collection, false otherwise.
+ */
+ public function contains(string $iana): bool
+ {
+ return array_any(
+ $this->items,
+ static fn(Timezone $timezone): bool => $timezone->value === $iana
+ );
+ }
+
+ /**
+ * Finds a Timezone by its IANA identifier.
+ *
+ * @param string $iana The IANA timezone identifier to search for (e.g. America/Sao_Paulo).
+ * @return Timezone|null The matching Timezone, or null if not found.
+ */
+ public function findByIdentifier(string $iana): ?Timezone
+ {
+ return array_find(
+ $this->items,
+ static fn(Timezone $timezone): bool => $timezone->value === $iana
+ );
+ }
+
+ /**
+ * Returns all timezone identifiers as plain strings.
+ *
+ * @return list The list of IANA timezone identifier strings.
+ */
+ public function toStrings(): array
+ {
+ return array_map(
+ static fn(Timezone $timezone): string => $timezone->toString(),
+ $this->items
+ );
+ }
+}
diff --git a/tests/InstantTest.php b/tests/InstantTest.php
new file mode 100644
index 0000000..186902e
--- /dev/null
+++ b/tests/InstantTest.php
@@ -0,0 +1,369 @@
+toDateTimeImmutable()->getTimezone()->getName());
+ }
+
+ public function testInstantNowIsCloseToCurrentTime(): void
+ {
+ /** @Given the current Unix timestamp before creating the Instant */
+ $before = time();
+
+ /** @When creating an Instant from now */
+ $instant = Instant::now();
+
+ /** @And capturing the Unix timestamp after */
+ $after = time();
+
+ /** @Then the Instant's Unix seconds should be within the before/after window */
+ self::assertGreaterThanOrEqual($before, $instant->toUnixSeconds());
+ self::assertLessThanOrEqual($after, $instant->toUnixSeconds());
+ }
+
+ public function testInstantNowPreservesMicrosecondPrecision(): void
+ {
+ /** @Given an Instant created from now */
+ $instant = Instant::now();
+
+ /** @When formatting the underlying DateTimeImmutable with microseconds */
+ $microseconds = (int)$instant->toDateTimeImmutable()->format('u');
+
+ /** @Then the microsecond component should be representable (six digits available) */
+ self::assertGreaterThanOrEqual(0, $microseconds);
+ self::assertLessThanOrEqual(999999, $microseconds);
+ }
+
+ public function testInstantNowIso8601HasNoFractionalSeconds(): void
+ {
+ /** @Given an Instant created from now */
+ $instant = Instant::now();
+
+ /** @When formatting as ISO 8601 */
+ $iso = $instant->toIso8601();
+
+ /** @Then the output should match YYYY-MM-DDTHH:MM:SS+00:00 without fractions */
+ self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/', $iso);
+ }
+
+ public function testInstantNowProducesDistinctInstances(): void
+ {
+ /** @Given two Instants created from now in sequence */
+ $first = Instant::now();
+ $second = Instant::now();
+
+ /** @Then both should be valid Instants in UTC */
+ self::assertSame('UTC', $first->toDateTimeImmutable()->getTimezone()->getName());
+ self::assertSame('UTC', $second->toDateTimeImmutable()->getTimezone()->getName());
+
+ /** @And the second should not be before the first */
+ self::assertGreaterThanOrEqual(
+ $first->toDateTimeImmutable()->format('U.u'),
+ $second->toDateTimeImmutable()->format('U.u')
+ );
+ }
+
+ #[DataProvider('validStringsDataProvider')]
+ public function testInstantFromString(
+ string $value,
+ string $expectedIso8601,
+ int $expectedUnixSeconds
+ ): void {
+ /** @Given a valid date-time string with offset */
+ /** @When creating an Instant from the string */
+ $instant = Instant::fromString(value: $value);
+
+ /** @Then the ISO 8601 representation should match the expected UTC value */
+ self::assertSame($expectedIso8601, $instant->toIso8601());
+
+ /** @And the Unix seconds should match the expected timestamp */
+ self::assertSame($expectedUnixSeconds, $instant->toUnixSeconds());
+ }
+
+ #[DataProvider('unixSecondsDataProvider')]
+ public function testInstantFromUnixSeconds(
+ int $seconds,
+ string $expectedIso8601
+ ): void {
+ /** @Given a valid Unix timestamp in seconds */
+ /** @When creating an Instant from Unix seconds */
+ $instant = Instant::fromUnixSeconds(seconds: $seconds);
+
+ /** @Then the ISO 8601 representation should match the expected UTC value */
+ self::assertSame($expectedIso8601, $instant->toIso8601());
+
+ /** @And the Unix seconds should round-trip correctly */
+ self::assertSame($seconds, $instant->toUnixSeconds());
+ }
+
+ public function testInstantToDateTimeImmutableReturnsUtc(): void
+ {
+ /** @Given an Instant created from a string with a non-UTC offset */
+ $instant = Instant::fromString(value: '2026-02-17T15:30:00+05:00');
+
+ /** @When converting to DateTimeImmutable */
+ $dateTime = $instant->toDateTimeImmutable();
+
+ /** @Then the timezone should be UTC */
+ self::assertSame('UTC', $dateTime->getTimezone()->getName());
+
+ /** @And the date-time should reflect the UTC-converted value */
+ self::assertSame('2026-02-17T10:30:00', $dateTime->format('Y-m-d\TH:i:s'));
+ }
+
+ public function testInstantFromStringNormalizesToUtc(): void
+ {
+ /** @Given a date-time string with a positive offset */
+ $instant = Instant::fromString(value: '2026-02-17T18:00:00+03:00');
+
+ /** @Then the ISO 8601 output should be normalized to UTC */
+ self::assertSame('2026-02-17T15:00:00+00:00', $instant->toIso8601());
+
+ /** @And the DateTimeImmutable timezone should be UTC */
+ self::assertSame('UTC', $instant->toDateTimeImmutable()->getTimezone()->getName());
+ }
+
+ public function testInstantFromStringWithNegativeOffset(): void
+ {
+ /** @Given a date-time string with a negative offset */
+ $instant = Instant::fromString(value: '2026-02-17T07:00:00-05:00');
+
+ /** @Then the ISO 8601 output should be normalized to UTC */
+ self::assertSame('2026-02-17T12:00:00+00:00', $instant->toIso8601());
+ }
+
+ public function testInstantFromStringWithUtcOffset(): void
+ {
+ /** @Given a date-time string already in UTC */
+ $instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00');
+
+ /** @Then the ISO 8601 output should remain unchanged */
+ self::assertSame('2026-02-17T10:30:00+00:00', $instant->toIso8601());
+ }
+
+ public function testInstantFromUnixSecondsEpoch(): void
+ {
+ /** @Given Unix timestamp zero (epoch) */
+ $instant = Instant::fromUnixSeconds(seconds: 0);
+
+ /** @Then the ISO 8601 output should be the Unix epoch in UTC */
+ self::assertSame('1970-01-01T00:00:00+00:00', $instant->toIso8601());
+
+ /** @And the Unix seconds should be zero */
+ self::assertSame(0, $instant->toUnixSeconds());
+ }
+
+ public function testInstantFromUnixSecondsNegativeValue(): void
+ {
+ /** @Given a negative Unix timestamp representing a date before the epoch */
+ $instant = Instant::fromUnixSeconds(seconds: -86400);
+
+ /** @Then the ISO 8601 output should be one day before the epoch */
+ self::assertSame('1969-12-31T00:00:00+00:00', $instant->toIso8601());
+
+ /** @And the Unix seconds should round-trip correctly */
+ self::assertSame(-86400, $instant->toUnixSeconds());
+ }
+
+ public function testInstantFromUnixSecondsToDateTimeImmutableIsUtc(): void
+ {
+ /** @Given an Instant created from Unix seconds */
+ $instant = Instant::fromUnixSeconds(seconds: 1771324200);
+
+ /** @When converting to DateTimeImmutable */
+ $dateTime = $instant->toDateTimeImmutable();
+
+ /** @Then the timezone should be UTC */
+ self::assertSame('UTC', $dateTime->getTimezone()->getName());
+ }
+
+ public function testInstantFromStringAndFromUnixSecondsProduceSameResult(): void
+ {
+ /** @Given an Instant created from a string */
+ $fromString = Instant::fromString(value: '2026-02-17T00:00:00+00:00');
+
+ /** @And an Instant created from the equivalent Unix seconds */
+ $fromUnix = Instant::fromUnixSeconds(seconds: $fromString->toUnixSeconds());
+
+ /** @Then both should produce the same ISO 8601 output */
+ self::assertSame($fromString->toIso8601(), $fromUnix->toIso8601());
+
+ /** @And both should produce the same Unix seconds */
+ self::assertSame($fromString->toUnixSeconds(), $fromUnix->toUnixSeconds());
+ }
+
+ public function testInstantDateTimeImmutablePreservesMicroseconds(): void
+ {
+ /** @Given an Instant created from a valid string */
+ $instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00');
+
+ /** @When accessing the underlying DateTimeImmutable */
+ $dateTime = $instant->toDateTimeImmutable();
+
+ /** @Then the format should support microsecond precision */
+ $formatted = $dateTime->format('Y-m-d\TH:i:s.u');
+ self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}$/', $formatted);
+ }
+
+ public function testInstantIso8601OutputNeverContainsFractionalSeconds(): void
+ {
+ /** @Given an Instant created from any valid input */
+ $instant = Instant::fromString(value: '2026-06-15T23:59:59+00:00');
+
+ /** @When formatting as ISO 8601 */
+ $iso = $instant->toIso8601();
+
+ /** @Then the output should match YYYY-MM-DDTHH:MM:SS+00:00 without fractions */
+ self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/', $iso);
+ }
+
+ public function testInstantFromStringWithDayBoundaryOffset(): void
+ {
+ /** @Given a date-time string where the UTC conversion crosses a day boundary */
+ $instant = Instant::fromString(value: '2026-02-18T01:00:00+03:00');
+
+ /** @Then the ISO 8601 output should reflect the previous day in UTC */
+ self::assertSame('2026-02-17T22:00:00+00:00', $instant->toIso8601());
+ }
+
+ public function testInstantFromStringWithMaxPositiveOffset(): void
+ {
+ /** @Given a date-time string with the maximum positive UTC offset (+14:00) */
+ $instant = Instant::fromString(value: '2026-02-17T14:00:00+14:00');
+
+ /** @Then the ISO 8601 output should be normalized to UTC */
+ self::assertSame('2026-02-17T00:00:00+00:00', $instant->toIso8601());
+ }
+
+ public function testInstantFromStringWithMaxNegativeOffset(): void
+ {
+ /** @Given a date-time string with the maximum negative UTC offset (-12:00) */
+ $instant = Instant::fromString(value: '2026-02-16T12:00:00-12:00');
+
+ /** @Then the ISO 8601 output should be normalized to UTC */
+ self::assertSame('2026-02-17T00:00:00+00:00', $instant->toIso8601());
+ }
+
+ #[DataProvider('invalidStringsDataProvider')]
+ public function testInstantWhenInvalidString(string $value): void
+ {
+ /** @Given an invalid date-time string */
+ /** @Then an InvalidInstant exception should be thrown */
+ $this->expectException(InvalidInstant::class);
+ $this->expectExceptionMessage(sprintf('The value <%s> could not be decoded into a valid instant.', $value));
+
+ /** @When trying to create an Instant from the invalid string */
+ Instant::fromString(value: $value);
+ }
+
+ public static function validStringsDataProvider(): array
+ {
+ return [
+ 'UTC offset' => [
+ 'value' => '2026-02-17T10:30:00+00:00',
+ 'expectedIso8601' => '2026-02-17T10:30:00+00:00',
+ 'expectedUnixSeconds' => 1771324200
+ ],
+ 'Midnight UTC' => [
+ 'value' => '2026-01-01T00:00:00+00:00',
+ 'expectedIso8601' => '2026-01-01T00:00:00+00:00',
+ 'expectedUnixSeconds' => 1767225600
+ ],
+ 'End of day UTC' => [
+ 'value' => '2026-02-17T23:59:59+00:00',
+ 'expectedIso8601' => '2026-02-17T23:59:59+00:00',
+ 'expectedUnixSeconds' => 1771372799
+ ],
+ 'Positive offset +05:30' => [
+ 'value' => '2026-02-17T16:00:00+05:30',
+ 'expectedIso8601' => '2026-02-17T10:30:00+00:00',
+ 'expectedUnixSeconds' => 1771324200
+ ],
+ 'Negative offset -03:00' => [
+ 'value' => '2026-02-17T07:30:00-03:00',
+ 'expectedIso8601' => '2026-02-17T10:30:00+00:00',
+ 'expectedUnixSeconds' => 1771324200
+ ],
+ 'Negative offset -05:00' => [
+ 'value' => '2026-02-17T05:30:00-05:00',
+ 'expectedIso8601' => '2026-02-17T10:30:00+00:00',
+ 'expectedUnixSeconds' => 1771324200
+ ],
+ 'Positive offset +09:00' => [
+ 'value' => '2026-02-17T19:30:00+09:00',
+ 'expectedIso8601' => '2026-02-17T10:30:00+00:00',
+ 'expectedUnixSeconds' => 1771324200
+ ],
+ 'Negative offset -09:30' => [
+ 'value' => '2026-02-17T01:00:00-09:30',
+ 'expectedIso8601' => '2026-02-17T10:30:00+00:00',
+ 'expectedUnixSeconds' => 1771324200
+ ]
+ ];
+ }
+
+ public static function unixSecondsDataProvider(): array
+ {
+ return [
+ 'Epoch' => [
+ 'seconds' => 0,
+ 'expectedIso8601' => '1970-01-01T00:00:00+00:00'
+ ],
+ 'Year 2000 midnight' => [
+ 'seconds' => 946684800,
+ 'expectedIso8601' => '2000-01-01T00:00:00+00:00'
+ ],
+ 'One day after epoch' => [
+ 'seconds' => 86400,
+ 'expectedIso8601' => '1970-01-02T00:00:00+00:00'
+ ],
+ 'One day before epoch' => [
+ 'seconds' => -86400,
+ 'expectedIso8601' => '1969-12-31T00:00:00+00:00'
+ ],
+ 'Year 2026 reference' => [
+ 'seconds' => 1771324200,
+ 'expectedIso8601' => '2026-02-17T10:30:00+00:00'
+ ],
+ 'Large future timestamp' => [
+ 'seconds' => 2147483647,
+ 'expectedIso8601' => '2038-01-19T03:14:07+00:00'
+ ]
+ ];
+ }
+
+ public static function invalidStringsDataProvider(): array
+ {
+ return [
+ 'Date only' => ['value' => '2026-02-17'],
+ 'Time only' => ['value' => '10:30:00'],
+ 'Plain text' => ['value' => 'not-a-date'],
+ 'Invalid day' => ['value' => '2026-02-30T10:30:00+00:00'],
+ 'Empty string' => ['value' => ''],
+ 'Invalid month' => ['value' => '2026-13-17T10:30:00+00:00'],
+ 'Missing offset' => ['value' => '2026-02-17T10:30:00'],
+ 'Truncated offset' => ['value' => '2026-02-17T10:30:00+00'],
+ 'Slash-separated date' => ['value' => '2026/02/17T10:30:00+00:00'],
+ 'Missing time separator' => ['value' => '2026-02-17 10:30:00+00:00'],
+ 'Z suffix instead offset' => ['value' => '2026-02-17T10:30:00Z'],
+ 'With fractional seconds' => ['value' => '2026-02-17T10:30:00.123456+00:00'],
+ 'Unix timestamp as string' => ['value' => '1771324200']
+ ];
+ }
+}
diff --git a/tests/TimezoneTest.php b/tests/TimezoneTest.php
new file mode 100644
index 0000000..d873d11
--- /dev/null
+++ b/tests/TimezoneTest.php
@@ -0,0 +1,112 @@
+value);
+ }
+
+ public function testTimezoneUtcToStringReturnsUtc(): void
+ {
+ /** @Given a UTC Timezone */
+ $timezone = Timezone::utc();
+
+ /** @When converting to string */
+ $result = $timezone->toString();
+
+ /** @Then the result should be UTC */
+ self::assertSame('UTC', $result);
+ }
+
+ public function testTimezoneUtcToDateTimeZoneReturnsUtc(): void
+ {
+ /** @Given a UTC Timezone */
+ $timezone = Timezone::utc();
+
+ /** @When converting to DateTimeZone */
+ $dateTimeZone = $timezone->toDateTimeZone();
+
+ /** @Then the DateTimeZone name should be UTC */
+ self::assertSame('UTC', $dateTimeZone->getName());
+ }
+
+ #[DataProvider('validIdentifiersDataProvider')]
+ public function testTimezoneFromValidIdentifier(string $identifier): void
+ {
+ /** @Given a valid IANA timezone identifier */
+ /** @When creating a Timezone from the identifier */
+ $timezone = Timezone::from(identifier: $identifier);
+
+ /** @Then the value should match the given identifier */
+ self::assertSame($identifier, $timezone->value);
+
+ /** @And toString should return the same identifier */
+ self::assertSame($identifier, $timezone->toString());
+ }
+
+ #[DataProvider('validIdentifiersDataProvider')]
+ public function testTimezoneToDateTimeZoneMatchesIdentifier(string $identifier): void
+ {
+ /** @Given a Timezone created from a valid identifier */
+ $timezone = Timezone::from(identifier: $identifier);
+
+ /** @When converting to DateTimeZone */
+ $dateTimeZone = $timezone->toDateTimeZone();
+
+ /** @Then the DateTimeZone name should match the original identifier */
+ self::assertSame($identifier, $dateTimeZone->getName());
+ }
+
+ #[DataProvider('invalidIdentifiersDataProvider')]
+ public function testTimezoneWhenInvalidIdentifier(string $identifier): void
+ {
+ /** @Given an invalid timezone identifier */
+ /** @Then an InvalidTimezone exception should be thrown */
+ $this->expectException(InvalidTimezone::class);
+ $this->expectExceptionMessage(sprintf('Timezone <%s> is invalid.', $identifier));
+
+ /** @When trying to create a Timezone from the invalid identifier */
+ Timezone::from(identifier: $identifier);
+ }
+
+ public static function validIdentifiersDataProvider(): array
+ {
+ return [
+ 'UTC' => ['identifier' => 'UTC'],
+ 'Asia/Tokyo' => ['identifier' => 'Asia/Tokyo'],
+ 'Asia/Kolkata' => ['identifier' => 'Asia/Kolkata'],
+ 'Europe/London' => ['identifier' => 'Europe/London'],
+ 'Pacific/Auckland' => ['identifier' => 'Pacific/Auckland'],
+ 'Australia/Sydney' => ['identifier' => 'Australia/Sydney'],
+ 'America/New_York' => ['identifier' => 'America/New_York'],
+ 'America/Sao_Paulo' => ['identifier' => 'America/Sao_Paulo']
+ ];
+ }
+
+ public static function invalidIdentifiersDataProvider(): array
+ {
+ return [
+ 'Spaces' => ['identifier' => 'America/ New_York'],
+ 'Partial' => ['identifier' => 'America/'],
+ 'Plain text' => ['identifier' => 'Invalid/Timezone'],
+ 'Abbreviation' => ['identifier' => 'EST'],
+ 'Empty string' => ['identifier' => ''],
+ 'Numeric offset' => ['identifier' => '+00:00']
+ ];
+ }
+}
diff --git a/tests/TimezonesTest.php b/tests/TimezonesTest.php
new file mode 100644
index 0000000..b3a9356
--- /dev/null
+++ b/tests/TimezonesTest.php
@@ -0,0 +1,186 @@
+count());
+
+ /** @And the item should match the original Timezone */
+ self::assertSame('America/Sao_Paulo', $timezones->all()[0]->value);
+ }
+
+ public function testTimezonesFromMultipleTimezones(): void
+ {
+ /** @Given multiple Timezone objects */
+ $first = Timezone::from(identifier: 'America/Sao_Paulo');
+ $second = Timezone::from(identifier: 'America/New_York');
+ $third = Timezone::from(identifier: 'Asia/Tokyo');
+
+ /** @When creating a Timezones collection */
+ $timezones = Timezones::from($first, $second, $third);
+
+ /** @Then the collection should contain all three items */
+ self::assertSame(3, $timezones->count());
+
+ /** @And they should be in the same order */
+ self::assertSame('America/Sao_Paulo', $timezones->all()[0]->value);
+ self::assertSame('America/New_York', $timezones->all()[1]->value);
+ self::assertSame('Asia/Tokyo', $timezones->all()[2]->value);
+ }
+
+ public function testTimezonesFromStrings(): void
+ {
+ /** @Given valid IANA identifier strings */
+ /** @When creating a Timezones collection from strings */
+ $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Europe/London');
+
+ /** @Then the collection should contain all three items */
+ self::assertSame(3, $timezones->count());
+
+ /** @And the values should match the input order */
+ self::assertSame(['UTC', 'America/Sao_Paulo', 'Europe/London'], $timezones->toStrings());
+ }
+
+ public function testTimezonesFromStringsWithInvalidIdentifier(): void
+ {
+ /** @Given a mix of valid and invalid identifier strings */
+ /** @Then an InvalidTimezone exception should be thrown */
+ $this->expectException(InvalidTimezone::class);
+ $this->expectExceptionMessage('Timezone is invalid.');
+
+ /** @When creating a Timezones collection with the invalid identifier */
+ Timezones::fromStrings('UTC', 'Invalid/Zone');
+ }
+
+ public function testTimezonesContainsReturnsTrueForExistingIdentifier(): void
+ {
+ /** @Given a Timezones collection with known identifiers */
+ $timezones = Timezones::fromStrings('America/Sao_Paulo', 'America/New_York');
+
+ /** @Then contains should return true for an existing identifier */
+ self::assertTrue($timezones->contains(iana: 'America/Sao_Paulo'));
+ }
+
+ public function testTimezonesContainsReturnsFalseForMissingIdentifier(): void
+ {
+ /** @Given a Timezones collection with known identifiers */
+ $timezones = Timezones::fromStrings('America/Sao_Paulo', 'America/New_York');
+
+ /** @Then contains should return false for a non-existing identifier */
+ self::assertFalse($timezones->contains(iana: 'Asia/Tokyo'));
+ }
+
+ public function testTimezonesFindByIdentifierReturnsMatchingTimezone(): void
+ {
+ /** @Given a Timezones collection with multiple identifiers */
+ $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Asia/Tokyo');
+
+ /** @When searching for an existing identifier */
+ $found = $timezones->findByIdentifier(iana: 'Asia/Tokyo');
+
+ /** @Then the matching Timezone should be returned */
+ self::assertNotNull($found);
+ self::assertSame('Asia/Tokyo', $found->value);
+ }
+
+ public function testTimezonesFindByIdentifierReturnsNullWhenNotFound(): void
+ {
+ /** @Given a Timezones collection without Europe/London */
+ $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo');
+
+ /** @When searching for a non-existing identifier */
+ $found = $timezones->findByIdentifier(iana: 'Europe/London');
+
+ /** @Then null should be returned */
+ self::assertNull($found);
+ }
+
+ public function testTimezonesCountMatchesAllSize(): void
+ {
+ /** @Given a Timezones collection with four items */
+ $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Asia/Tokyo', 'Europe/London');
+
+ /** @Then count() should match the number of items in all() */
+ self::assertCount($timezones->count(), $timezones->all());
+ }
+
+ public function testTimezonesIsCountable(): void
+ {
+ /** @Given a Timezones collection */
+ $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo');
+
+ /** @Then the native count() function should work */
+ self::assertSame(2, count($timezones));
+ }
+
+ public function testTimezonesToStringsReturnsPlainIdentifiers(): void
+ {
+ /** @Given a Timezones collection */
+ $timezones = Timezones::fromStrings('America/Sao_Paulo', 'Asia/Tokyo');
+
+ /** @When converting to strings */
+ $strings = $timezones->toStrings();
+
+ /** @Then each element should match its corresponding Timezone value */
+ $all = $timezones->all();
+
+ foreach ($strings as $index => $string) {
+ self::assertIsString($string);
+ self::assertSame($all[$index]->value, $string);
+ }
+ }
+
+ public function testTimezonesFromEmptyReturnsEmptyCollection(): void
+ {
+ /** @Given no Timezone objects */
+ /** @When creating an empty Timezones collection */
+ $timezones = Timezones::from();
+
+ /** @Then the collection should be empty */
+ self::assertSame(0, $timezones->count());
+ self::assertSame([], $timezones->all());
+ self::assertSame([], $timezones->toStrings());
+ }
+
+ public function testTimezonesPreservesInsertionOrder(): void
+ {
+ /** @Given identifiers in a specific order */
+ $identifiers = ['Pacific/Auckland', 'Asia/Tokyo', 'UTC', 'America/New_York'];
+
+ /** @When creating a collection from those strings */
+ $timezones = Timezones::fromStrings(...$identifiers);
+
+ /** @Then toStrings should preserve the original order */
+ self::assertSame($identifiers, $timezones->toStrings());
+ }
+
+ public function testTimezonesCreatedFromSameIdentifiersAreConsistent(): void
+ {
+ /** @Given two Timezones collections created from the same identifiers */
+ $first = Timezones::fromStrings('UTC', 'America/Sao_Paulo');
+ $second = Timezones::fromStrings('UTC', 'America/Sao_Paulo');
+
+ /** @Then their string representations should be identical */
+ self::assertSame($first->toStrings(), $second->toStrings());
+
+ /** @And their counts should match */
+ self::assertSame($first->count(), $second->count());
+ }
+}