Різниця між self і static зводиться до моменту резолву. self і parent PHP розвʼязує на етапі компіляції за тим, у якому класі фізично написано рядок коду: self вказує на той самий клас, parent на його батька. static розвʼязується в рантаймі й дає «викликаний клас» (called class), тобто той, з якого виклик стартував. Це значення PHP тягне в кадрі виклику разом із $this і скоупом, тому CsvReport::make() виконує тіло методу, написане в Report, але static усередині означає CsvReport. Механізм зʼявився в PHP 5.3 і називається late static binding: «late» тут про те, що привʼязка відкладена з компіляції на виконання. Прочитати викликаний клас можна через static::class (з PHP 5.5) або get_called_class(), а $obj::class доступний з PHP 8.0.
LSB охоплює чотири речі: new static(), static::method(), static::CONSTANT і static::$property. Приклад у коді показує обидва боки одразу. make() написаний один раз у базовому класі, але повертає той клас, на якому його викликали, і тип повернення static (PHP 8.0+) чесно про це говорить аналізаторам і IDE. А fileName() з self::FORMAT для будь-якого нащадка вперто віддає report.txt, бо звернення до константи вже скомпільовано в Report::FORMAT. Та сама історія з методами: self::render() в базовому класі викличе саме базову реалізацію, навіть якщо нащадок її перевизначив і навіть якщо ви в нестатичному контексті з живим $this. Якщо потрібен поліморфізм, пишіть $this->render() або static::render().
Окремо стоїть питання форвардингу. Виклики через self::, parent::, static:: і forward_static_call() передають викликаний клас далі: якщо нащадок робить parent::make(), то всередині Report::make() static усе ще вказує на нащадка. Явне звернення на кшталт Report::make() форвардингом не є і скидає викликаний клас на Report, тому в бібліотечному коді такий виклик ламає всю ідею фабрики. Друга межа проходить по private-методах: вони не перевизначаються, і якщо нащадок оголосить private-метод з тим самим імʼям, static::foo() з коду батька впаде фатальною помилкою про виклик private-методу з чужого скоупу. Тож static:: доречний лише для того, що нащадок реально може перевизначити.
Найдорожча помилка з LSB стосується статичних властивостей. static::$generated вибирає клас у рантаймі, але не створює нащадку окремої комірки: властивість існує в одному екземплярі там, де її оголошено, а нащадки лише дивляться на той самий слот. У прикладі Report::$generated після виклику CsvReport::make() дорівнює одиниці, і другий нащадок додаватиме туди ж. Через це саморобні синглтони виду static::$instance ??= new static() тихо повертають один обʼєкт на всю ієрархію: перший, хто встиг. Якщо лічильник чи кеш справді потрібен окремий, ключуйте його класом (static::$perClass[static::class]) або переоголошуйте властивість у кожному нащадку. Здоровіше рішення - віддати час життя обʼєкта DI-контейнеру й не тягнути глобальний стан у статику взагалі.
static доречний у named constructors і флюентних API, де базовий клас має повертати конкретний підтип: Collection::make(), withX()-методи, білдери запитів. Шкодить він там, де ви не готові підтримувати контракт конструктора: new static() мовчки обіцяє, що всі нащадки створюються тими самими аргументами, і перший нащадок з іншою сигнатурою ламає фабрику в рантаймі. Саме на це лається правило про небезпечне new static() у phpstan-strict-rules, і воно замовкає, коли клас або конструктор оголошено final. Робоче правило: static беріть для того, що ви свідомо віддаєте нащадкам, self - для внутрішньої механіки, яку перевизначати ніхто не має. А в final-класі різниці немає взагалі, тож вигадувати там гнучкість, якої ніхто не використає, немає сенсу.
declare(strict_types=1);
abstract class Report
{
public const FORMAT = 'txt';
/** Оголошено один раз - слот памʼяті один на всю ієрархію. */
protected static int $generated = 0;
/** @var array<class-string, int> лічильник окремо для кожного класу */
protected static array $perClass = [];
/** Тип повернення static дозволений з PHP 8.0. */
public static function make(): static
{
static::$generated++; // спільний лічильник, не «свій» у нащадка
$class = static::class; // викликаний клас, а не Report
static::$perClass[$class] = (static::$perClass[$class] ?? 0) + 1;
return new static(); // Report::make() тут впаде: клас абстрактний
}
public function fileName(): string
{
return 'report.'.self::FORMAT; // self зафіксовано на компіляції: завжди txt
}
public function realFileName(): string
{
return 'report.'.static::FORMAT; // LSB: константа береться з викликаного класу
}
}
final class CsvReport extends Report
{
public const FORMAT = 'csv';
}
$report = CsvReport::make();
echo $report::class; // CsvReport - результат new static()
echo $report->fileName(); // report.txt - класична пастка self::
echo $report->realFileName(); // report.csv
echo Report::$generated; // 1: інкремент пішов у слот базового класу
Одна фраза, яка закриває питання: «`self` - це там, де код написано, `static` - це те, на чому виклик почався». Далі одразу дайте приклад різниці: `new self()` у фабриці базового класу назавжди прибиває результат до базового класу, а `new static()` віддає нащадка. І згадайте статичні властивості: LSB вибирає клас для читання, але не створює нащадку окремий слот памʼяті - на цьому валиться половина саморобних синглтонів.