Статика в PHP: когда она нужна и почему она мешает тестам

Статика — это методы и свойства, которые принадлежат классу целиком, а не конкретному объекту: один экземпляр на всё приложение, доступ без new. Такой код сцеплен с конкретным классом: вместо зависимости по контракту — жёсткий вызов Settings::get(), подменить который нельзя. Зависимости при этом скрыты: глядя на конструктор класса, не видно, что внутри он ещё и от статики зависит. А поведение заморожено: у статического вызова нет объекта, которому можно передать другую реализацию, поэтому такой код не мокается и не тестируется изолированно.


Зачем это нужно

Боль из практики: в проекте отчётов данные для графиков берутся через Statistics::getDailyRevenue(). Всё работает — до дня, когда надо написать тест. А дальше — ступор: Statistics внутри лезет в базу напрямую, подменить его нечем — нет ни интерфейса, ни объекта, ни точки внедрения. В тесте либо гоняют реальную базу, либо делают «выкрутасы» с рефлексией. Проблема не в том, что статика «плохая», а в том, что статический вызов невозможно заменить — и весь класс-потребитель намертво к нему прицеплен.

Как это работает

Статический метод и свойство объявляются словом static. Обращение — через имя класса или self/static внутри самого класса:

php
class Settings
{
    private static array $cache = [];   // свойство класса, не объекта

    public static function get(string $key): ?string
    {
        return static::$cache[$key] ?? null;
    }
}

// Вызов без создания объекта
$theme = Settings::get('theme');

Важный момент: статическое свойство живёт в классе и общее для всех. Пусть у нас есть счётчик:

php
class Counter
{
    public static int $created = 0;

    public function __construct()
    {
        self::$created++;   // один и тот же счётчик для всех объектов
    }
}

new Counter();
new Counter();
echo Counter::$created;  // 2 — не «свой» у каждого объекта, а общий

Именно общее состояние делает статику удобной (кеш, счётчики, реестры) и одновременно опасной: в многопоточном воркере или при параллельных тестах такое состояние течёт от одного запроса к другому.

Теперь про self против static — классический вопрос-ловушка:

php
class 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) — чистая функцияТак и оставляем: это легальная статика

Подводный камень

  1. Общее состояние течёт между запросами. В Laravel Octane, Swoole и любом воркере статическое свойство живёт в памяти процесса: положили значение в один запрос — в следующий оно там же. Состояние держите в контейнере, Redis или базе.
  2. Статику нельзя мокнуть стандартными средствами. Появляются «костыли»: библиотеки типа Mockery-переопределения, рефлексия, запуск тестов в отдельных процессах — каждый костыль тормозит и ломается. Если класс тестировать невозможно — это сигнал, а не повод для хака.
  3. self:: в наследовании — тихий баг. Написали self::type() в базовом классе, наследник переопределил метод — а вызов всё равно идёт к базовому. В статике по умолчанию пишите static::, self:: оставляйте только там, где жёсткая привязка к текущему классу — осознанно.
  4. Статический конструктор-инициализатор. Config::init() перед использованием — скрытая последовательность вызовов: забыли вызвать — всё падает в рантайме. Инициализацию делайте через конструктор объекта или контейнер.

Мини-шпаргалка

php
// Легальная статика: чистые хелперы и фабрики
Str::slug('Hello World');        // 'hello-world'
Order::fromArray($data);         // фабричный метод

// Обращение
Settings::get('key');            // из любого места
self::$cache;                    // внутри класса: свой класс
static::$cache;                  // внутри класса: класс вызывающего (позднее связывание)

// Что не делать
DB::table('orders')->get();      // внутри метода — тестируемость нулевая

Три вывода

  1. Статика — привязка к классу, а не объекту: удобно для чистых хелперов и фабрик, опасно для всего, что лезет в базу, сеть и файлы.
  2. Главный минус статики — невозможность подменить реализацию: такой код не мокается, и тесты либо гоняют всё по-настоящему, либо ломаются.
  3. В статике пишите static::, а не self:: — иначе наследник молча не переопределит поведение, а баг проявится в проде.

Вопрос-ловушка

Спрашивают так: «Чем self отличается от static?» Ожидают не «self — это сам класс, а static — позднее связывание» в общих словах, а умение показать разницу кодом: пример с базовым классом и наследником, где self::type() всегда вернёт метод базового, а static::type() — метод того класса, через который пришёл вызов. Хитрый поворот — «а что вернёт new self внутри метода наследника?» Ответ: объект базового класса, потому что self жёстко привязан к месту написания. Тот, кто отвечал «наследника», — только читал про статическое связывание, а не сталкивался с ним в коде.