Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .ai-factory/patches/2026-10-14-21.44.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Protected cache members broke existing transformer subclasses

**Date:** 2026-10-14 21:44
**Files:** `src/Transformers/DateTimeTransformer.php`, `tests/Unit/Transformers/DateTimeTest.php`
**Severity:** medium

## Problem

Existing `DateTimeTransformer` subclasses could fail to load when they already declared `configuration()`, `$configuration`, or `$transforming` with unrelated types.

## Root Cause

The configuration cache introduced protected members into an extensible class. PHP required matching inherited signatures and property types, although these names had previously been available to consumers.

## Solution

Made the three cache members private while preserving the original protected extension points. Added independent regression tests for the method and both property collisions. Each test reproduced a fatal declaration error before the fix and passed afterward, checking the formatted date and unchanged consumer members.

## Prevention

- Keep new implementation details private in extensible package classes.
- Test subclasses with existing members of the same name and incompatible types.
- Verify parent internals do not dispatch to or overwrite unrelated consumer members.

## Tags

`#php` `#inheritance` `#backward-compatibility` `#regression`
35 changes: 30 additions & 5 deletions src/Transformers/DateTimeTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use DateTimeInterface;
use DragonCode\LaravelFeed\Contracts\Transformer;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Support\Carbon;

use function config;
Expand All @@ -14,6 +15,10 @@ class DateTimeTransformer implements Transformer
{
protected ?string $dateFormat = null;

private ?Repository $configuration = null;

private bool $transforming = false;

public function __construct(?string $dateFormat = null)
{
$this->dateFormat = $dateFormat;
Expand All @@ -27,9 +32,20 @@ public function allow(mixed $value): bool
/** @param DateTimeInterface $value */
public function transform(mixed $value): string
{
return $this->resolve($value)
->when($this->timezone(), fn (Carbon $date, string $zone) => $date->setTimezone($zone))
->format($this->format());
$previousConfiguration = $this->configuration;
$previousTransforming = $this->transforming;

$this->configuration = null;
$this->transforming = true;

try {
return $this->resolve($value)
->when($this->timezone(), fn (Carbon $date, string $zone) => $date->setTimezone($zone))
->format($this->format());
} finally {
$this->configuration = $previousConfiguration;
$this->transforming = $previousTransforming;
}
}

protected function resolve(mixed $date): Carbon
Expand All @@ -39,11 +55,20 @@ protected function resolve(mixed $date): Carbon

protected function format(): string
{
return $this->dateFormat ?? config('feeds.date.format');
return $this->dateFormat ?? ($this->configuration ?? $this->configuration())->get('feeds.date.format');
}

protected function timezone(): string
{
return config('feeds.date.timezone');
return ($this->configuration ?? $this->configuration())->get('feeds.date.timezone');
}

private function configuration(): Repository
{
if (! $this->transforming) {
return config();
}

return $this->configuration ??= config();
}
}
220 changes: 220 additions & 0 deletions tests/Unit/Transformers/DateTimeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
declare(strict_types=1);

use DragonCode\LaravelFeed\Transformers\DateTimeTransformer;
use Illuminate\Config\Repository;
use Illuminate\Container\Container;
use Illuminate\Support\Carbon;

test('formats DateTimeInterface to ATOM in UTC by default', function (DateTimeInterface $value, string $expected) {
$transformer = new DateTimeTransformer;
Expand Down Expand Up @@ -52,3 +55,220 @@ public function __construct() {}

expect($transformer->transform($date))->toBe('2026-07-28');
});

test('supports subclass configuration methods with unrelated return types', function () {
$transformer = new class extends DateTimeTransformer {
public function configuration(): array
{
return ['mode' => 'consumer'];
}
};

expect($transformer->transform(new DateTimeImmutable('2026-07-28T12:30:00+03:00')))
->toBe('2026-07-28T09:30:00+00:00')
->and($transformer->configuration())->toBe(['mode' => 'consumer']);
});

test('supports subclass configuration properties with unrelated types', function () {
$transformer = new class extends DateTimeTransformer {
public array $configuration = ['mode' => 'consumer'];
};

expect($transformer->transform(new DateTimeImmutable('2026-07-28T12:30:00+03:00')))
->toBe('2026-07-28T09:30:00+00:00')
->and($transformer->configuration)->toBe(['mode' => 'consumer']);
});

test('supports subclass transformation state with unrelated types', function () {
$transformer = new class extends DateTimeTransformer {
public string $transforming = 'consumer';
};

expect($transformer->transform(new DateTimeImmutable('2026-07-28T12:30:00+03:00')))
->toBe('2026-07-28T09:30:00+00:00')
->and($transformer->transforming)->toBe('consumer');
});

test('resolves configuration once per transformation', function () {
$resolutions = 0;

app()->beforeResolving('config', function () use (&$resolutions) {
$resolutions++;
});

$transformer = new DateTimeTransformer;
$date = new DateTimeImmutable('2026-07-28T12:30:00+03:00');

expect($resolutions)->toBe(0);

expect($transformer->transform($date))->toBe('2026-07-28T09:30:00+00:00')
->and($resolutions)->toBe(1);

expect($transformer->transform($date))->toBe('2026-07-28T09:30:00+00:00')
->and($resolutions)->toBe(2);
});

test('reads updated configuration without changing input dates', function (string $class) {
$transformer = new DateTimeTransformer;
$date = new $class('2026-07-28T23:30:00+03:00');
$before = $date->format('Y-m-d\TH:i:s.uP e');

expect($transformer->transform($date))->toBe('2026-07-28T20:30:00+00:00');

config()->set('feeds.date', ['format' => 'Y-m-d H:i', 'timezone' => '+12:00']);

expect($transformer->transform($date))->toBe('2026-07-29 08:30')
->and($date->format('Y-m-d\TH:i:s.uP e'))->toBe($before);
})->with([DateTime::class, DateTimeImmutable::class]);

test('reads a replaced configuration repository on the next transformation', function (string $binding) {
$application = app();
$transformer = new DateTimeTransformer;
$itemFormatter = new DateTimeTransformer('H:i');
$date = new DateTimeImmutable('2026-07-28T23:30:00+03:00');
$replacement = new Repository(config()->all());

expect($transformer->transform($date))->toBe('2026-07-28T20:30:00+00:00')
->and($itemFormatter->transform($date))->toBe('20:30');

$replacement->set('feeds.date', ['format' => 'Y-m-d H:i', 'timezone' => '+12:00']);

if ($binding === 'forgotten instance') {
$application->forgetInstance('config');
$application->instance('config', $replacement);
} elseif ($binding === 'instance') {
$application->instance('config', $replacement);
} else {
$application->{$binding}('config', fn (): Repository => $replacement);
}

expect($transformer->transform($date))->toBe('2026-07-29 08:30')
->and($itemFormatter->transform($date))->toBe('08:30');
})->with(['instance', 'singleton', 'bind', 'forgotten instance']);

test('reads configuration from the current container', function () {
$application = Container::getInstance();
$transformer = new DateTimeTransformer;
$date = new DateTimeImmutable('2026-07-28T23:30:00+03:00');
$replacement = new Container;
$repository = new Repository(config()->all());

expect($transformer->transform($date))->toBe('2026-07-28T20:30:00+00:00');

$repository->set('feeds.date', ['format' => 'Y-m-d H:i', 'timezone' => '+12:00']);
$replacement->instance('config', $repository);

try {
Container::setInstance($replacement);

expect($transformer->transform($date))->toBe('2026-07-29 08:30');
} finally {
Container::setInstance($application);
}

expect($transformer->transform($date))->toBe('2026-07-28T20:30:00+00:00');
});

test('resolves configuration after the date resolution extension', function () {
$repository = new Repository(config()->all());
$repository->set('feeds.date', ['format' => 'Y-m-d H:i', 'timezone' => '+12:00']);

$transformer = new class ($repository) extends DateTimeTransformer {
public function __construct(private readonly Repository $repository) {}

protected function resolve(mixed $date): Carbon
{
app()->instance('config', $this->repository);

return parent::resolve($date);
}
};

expect($transformer->transform(new DateTimeImmutable('2026-07-28T23:30:00+03:00')))
->toBe('2026-07-29 08:30');
});

test('supports format and timezone extensions without a configuration binding', function () {
$application = Container::getInstance();
$transformer = new class extends DateTimeTransformer {
protected function format(): string
{
return 'Y-m-d';
}

protected function timezone(): string
{
return '+12:00';
}
};

try {
Container::setInstance(new Container);

expect($transformer->transform(new DateTimeImmutable('2026-07-28T23:30:00+03:00')))
->toBe('2026-07-29');
} finally {
Container::setInstance($application);
}
});

test('reads current configuration through protected extensions after a failed transformation', function () {
$transformer = new class extends DateTimeTransformer {
public function settings(): array
{
return [$this->format(), $this->timezone()];
}
};
$date = new DateTimeImmutable('2026-07-28T23:30:00+03:00');
$replacement = new Repository(config()->all());

expect($transformer->settings())->toBe([DATE_ATOM, 'UTC']);

config()->set('feeds.date.timezone', 'Invalid/Timezone');

expect(fn () => $transformer->transform($date))->toThrow(InvalidArgumentException::class);

$replacement->set('feeds.date', ['format' => 'Y-m-d H:i', 'timezone' => '+12:00']);
app()->instance('config', $replacement);

expect($transformer->settings())->toBe(['Y-m-d H:i', '+12:00'])
->and($transformer->transform($date))->toBe('2026-07-29 08:30');

app()->instance('config', new Repository(['feeds' => ['date' => ['format' => 'Y', 'timezone' => 'UTC']]]));

expect($transformer->settings())->toBe(['Y', 'UTC']);
});

test('restores the active repository after a nested transformation', function () {
$transformer = new class extends DateTimeTransformer {
public ?string $nestedResult = null;

protected bool $nested = false;

protected function timezone(): string
{
$timezone = parent::timezone();

if (! $this->nested) {
$this->nested = true;

$repository = config();
$replacement = new Repository($repository->all());
$replacement->set('feeds.date', ['format' => 'H:i', 'timezone' => '+12:00']);
app()->instance('config', $replacement);

try {
$this->nestedResult = $this->transform(new DateTimeImmutable('2026-07-28T23:30:00+03:00'));
} finally {
app()->instance('config', $repository);
}
}

return $timezone;
}
};

expect($transformer->transform(new DateTimeImmutable('2026-07-28T23:30:00+03:00')))
->toBe('2026-07-28T20:30:00+00:00')
->and($transformer->nestedResult)->toBe('08:30');
});
Loading