Статика в PHP: когда она нужна и почему она мешает тестам
Статика — это методы и свойства, которые принадлежат классу целиком, а не конкретному объекту: один экземпляр на всё приложение, доступ без new. Такой код сцеплен с конкретным классом: вместо зависимости по контракту — жёсткий вызов Settings::get(), подменить который нельзя. Зависимости при этом скрыты: глядя на конструктор класса, не видно, что внутри он ещё и от статики зависит. А поведение заморожено: у статического вызова нет объекта, которому можно передать другую реализацию, поэтому такой код не мокается и не тестируется изолированно.
Зачем это нужно
Боль из практики: в проекте отчётов данные для графиков берутся через Statistics::getDailyRevenue(). Всё работает — до дня, когда надо написать тест. А дальше — ступор: Statistics внутри лезет в базу напрямую, подменить его нечем — нет ни интерфейса, ни объекта, ни точки внедрения. В тесте либо гоняют реальную базу, либо делают «выкрутасы» с рефлексией. Проблема не в том, что статика «плохая», а в том, что статический вызов невозможно заменить — и весь класс-потребитель намертво к нему прицеплен.
Как это работает
Статический метод и свойство объявляются словом static. Обращение — через имя класса или self/static внутри самого класса:
phpclass Settings
{
private static array $cache = []; // свойство класса, не объекта
public static function get(string $key): ?string
{
return static::$cache[$key] ?? null;
}
}
// Вызов без создания объекта
$theme = Settings::get('theme');
Важный момент: статическое свойство живёт в классе и общее для всех. Пусть у нас есть счётчик:
phpclass Counter
{
public static int $created = 0;
public function __construct()
{
self::$created++; // один и тот же счётчик для всех объектов
}
}
new Counter();
new Counter();
echo Counter::$created; // 2 — не «свой» у каждого объекта, а общий
Именно общее состояние делает статику удобной (кеш, счётчики, реестры) и одновременно опасной: в многопоточном воркере или при параллельных тестах такое состояние течёт от одного запроса к другому.
Теперь про self против static — классический вопрос-ловушка:
phpclass Report
{
public static function type(): string
{
return 'report';
}
public static function describe(): string
{
return self::type(); // жёстко: всегда Report::type()
// return static::type(); // позднее связывание: вызовется метод наследника
}
}
class DailyReport extends Report
{
public static function type(): string
{
return 'daily';
}
}
echo DailyReport::describe(); // с self: "report"; со static: "daily"
self указывает на класс, в котором написана строка, static — на класс, через который пришёл вызов. Второй механизм называется позднее статическое связывание, и он же работает в new static() — метод создания объекта, возвращающий класс наследника, а не базовый.
Когда статика уместна
Статика не враг — она враг там, где нужна замена:
| Ситуация | Статика |
|---|---|
Чистые хелперы: Str::slug(), Carbon::parse() — нет состояния, нет побочек | Да |
Фабричные методы: Order::fromArray(), Collection::make() | Да |
Ссылка на класс без объекта: Order::class в роутах, DI-контейнере | Да |
| Всё, что лезет в базу, сеть, файлы: репозитории, API-клиенты | Нет |
| Всё, что надо мокать в тестах | Нет |
Ориентир простой: если когда-нибудь понадобится подменить реализацию — статике не место.
Как это выглядит в Laravel
Laravel использует статику там, где она безопасна, и прячет там, где нужна замена:
php// Статика как фасад — под капотом это объект из контейнера
Cache::get('key'); // → app(CacheManager::class)->get('key')
// В модели — статика как фабрика и scope
Order::where('status', 'paid')->get(); // статический вызов — но это построитель запросов
User::factory()->create(); // фабрика — тоже статика
Фасады выглядят как статика, но под капотом резолвят объект из service container — поэтому в тестах Cache::fake() и Mail::fake() работают. Это не «настоящая» статика, а её презентация. Настоящая статика — свой собственный Statistics::getDailyRevenue() с прямым DB::table() внутри — вот она подмене не поддаётся.
Плохо / Хорошо
| Плохо | Хорошо |
|---|---|
Statistics::getRevenue() внутри контроллера | Внедрение через конструктор: private StatsService $stats |
| Статический кеш с mutable-состоянием в проекте на воркерах | Кеш через контейнер или внешний стор: Redis |
DB::table(...) в модели-методе, который надо тестировать | Репозиторий с интерфейсом, в тесте — мок |
| Статический хелпер с побочным эффектом: пишет в лог, шлёт письмо | Объект-сервис: подменяется в тестах |
Str::slug($title) — чистая функция | Так и оставляем: это легальная статика |
Подводный камень
- Общее состояние течёт между запросами. В Laravel Octane, Swoole и любом воркере статическое свойство живёт в памяти процесса: положили значение в один запрос — в следующий оно там же. Состояние держите в контейнере, Redis или базе.
- Статику нельзя мокнуть стандартными средствами. Появляются «костыли»: библиотеки типа Mockery-переопределения, рефлексия, запуск тестов в отдельных процессах — каждый костыль тормозит и ломается. Если класс тестировать невозможно — это сигнал, а не повод для хака.
self::в наследовании — тихий баг. Написалиself::type()в базовом классе, наследник переопределил метод — а вызов всё равно идёт к базовому. В статике по умолчанию пишитеstatic::,self::оставляйте только там, где жёсткая привязка к текущему классу — осознанно.- Статический конструктор-инициализатор.
Config::init()перед использованием — скрытая последовательность вызовов: забыли вызвать — всё падает в рантайме. Инициализацию делайте через конструктор объекта или контейнер.
Мини-шпаргалка
php// Легальная статика: чистые хелперы и фабрики
Str::slug('Hello World'); // 'hello-world'
Order::fromArray($data); // фабричный метод
// Обращение
Settings::get('key'); // из любого места
self::$cache; // внутри класса: свой класс
static::$cache; // внутри класса: класс вызывающего (позднее связывание)
// Что не делать
DB::table('orders')->get(); // внутри метода — тестируемость нулевая
Три вывода
- Статика — привязка к классу, а не объекту: удобно для чистых хелперов и фабрик, опасно для всего, что лезет в базу, сеть и файлы.
- Главный минус статики — невозможность подменить реализацию: такой код не мокается, и тесты либо гоняют всё по-настоящему, либо ломаются.
- В статике пишите
static::, а неself::— иначе наследник молча не переопределит поведение, а баг проявится в проде.
Вопрос-ловушка
Спрашивают так: «Чем self отличается от static?» Ожидают не «self — это сам класс, а static — позднее связывание» в общих словах, а умение показать разницу кодом: пример с базовым классом и наследником, где self::type() всегда вернёт метод базового, а static::type() — метод того класса, через который пришёл вызов. Хитрый поворот — «а что вернёт new self внутри метода наследника?» Ответ: объект базового класса, потому что self жёстко привязан к месту написания. Тот, кто отвечал «наследника», — только читал про статическое связывание, а не сталкивался с ним в коде.