Хабрахабр

Школа магии PHP

Что такое магия в PHP? Обычно под этим подразумевают методы вроде _construct() или __get(). Магические методы в PHP — это лазейки, которые помогают разработчикам выполнять удивительные вещи. В сети полно инструкций по их использованию, с которыми вы наверняка знакомы. Но что если мы скажем, что вы даже не видели настоящую магию? Ведь, чем больше вам кажется, что вы знаете все, тем больше магии ускользает от вас.

Главный и первый волшебный преподаватель школы — Александр Лисаченко (NightTiger). Давайте отбросим установленные рамки правил ООП и сделаем невозможное возможным в школе магии PHP. Он научит магическому мышлению и, возможно, вы полюбите магические методы, нестандартные способы доступа к свойствам, изменение контекстов, аспектно-ориентированное программирование и потоковые фильтры.

Александр Лисаченко — руководитель отдела веб-разработки и архитектуры в Альпари. Автор и ведущий разработчик аспектно-ориентированного фреймворка Go! AOP. Докладчик на международных конференциях по PHP.

В хорошем фильме «Иллюзия обмана» есть фраза:

«Чем вы ближе, тем меньше вы видите».

Это же можно сказать о PHP, как о магическом трюке, который позволяет проворачивать необычные вещи. Но прежде всего он создан, чтобы вас обмануть: «...an action that is intended to deceive, either as a way of cheating someone, or as a joke or form of entertainment».

Я проверну какой-нибудь трюк, и вы будете долго гадать, почему так происходит. Если мы возьмем PHP и вместе попытаемся на нем написать что-то магическое, скорее всего, я вас обману. Все потому, что PHP — это известный своими необычными штуками язык программирования.

Магическое снаряжение

Что нам потребуется из магического снаряжения? Знакомые до боли методы.

__construct(), __destruct(), __clone(),
__call(), __callStatic(), 
__get(), __set(), __isset(), __unset(),
__sleep(), __wakeup(), 
__toString(), __invoke(), __set_state(),
__debugInfo()

Но это не все. Последний метод отмечу отдельно — с ним можно проворачивать необычные вещи.

  • declare(ticks=1).
  • debug_backtrace(). Это наш спутник, чтобы понять, где мы находимся в текущем коде, посмотреть, кто и зачем нас вызвал, с какими аргументами. Пригодится, чтобы принять решение не выполнять всю логику.
  • unset(), isset(). Кажется, что ничего особенного, но эти конструкции скрывают много трюков, которые рассмотрим дальше.
  • by-reference passing. Когда мы передаем какой-то объект или переменную по ссылке, то стоит ожидать, что с вами неизбежно произойдет какая-нибудь магия.
  • bound closures. На замыканиях и том, что они могут биндиться, можно построить массу трюков.
  • Reflection API помогает вывести рефлексию на новый уровень.
  • StreamWrapper API.

Снаряжение готово — напомню первое правило магии.

Всегда будь самым умным парнем в комнате.

Трюк #1. Невозможное сравнение

Начнем с первого трюка, который я называю «Невозможное сравнение».
Посмотрите внимательно на код и подумайте, может ли такое произойти в PHP.

Есть переменная, объявляем её значение, а потом она внезапно сама себе не равна.

Not-a-number

Есть такой волшебный артефакт, как NaN — Not-a-number.

И в этом наш первый трюк: использовать NaN, чтобы озадачить товарища. Его удивительная особенность в том, что он сам себе не равен. Но NaN не единственное решение для этой задачи.

Используем константы

Незадачливый разработчик долго будет гадать, почему там true, а не false. Фишка в том, что мы можем для namespace объявить false как true и сравнить.

Обработчик

Следующий трюк — артиллерия помощнее, чем два предыдущих варианта. Рассмотрим, как он работает.

Трюк базируется на tick_function и, как я уже упоминал, declare(ticks=1).

Сперва объявляем некоторую функцию, и она по ссылке принимает параметр isMagic, а дальше пытается поменять это значение на true. Как это всё работает из коробки? В этом callback мы можем то значение, которое было раньше false, поменять на true. После того, как мы объявили declare(ticks=1), интерпретатор PHP после каждой элементарной операции вызывает register_tick_function — callback. Магия!

Трюк #2. Магические выражения

Возьмем пример, в котором объявлены две переменные. Одна из них false, другая true. Делаем isBlack и isWhite и var_dump’аем результат. Как вы думаете, что будет в итоге?

Правильный ответ false, потому что в PHP есть такое понятие, как «приоритет операторов». Приоритет операторов.

Поэтому происходит просто присваивание false. Удивительно, но у логического оператора or приоритет меньше, чем у операции присваивания. В isWhite может быть любое другое выражение, которое выполнится, если первая часть не отработает.

Магические выражения

Посмотрите на код ниже. Есть некоторый класс, который содержит конструктор, и некоторая фабрика, код которой будет далее.

Обратите внимание на последнюю строчку.

$value = new $factory->build(Foo::class);

Есть несколько вариантов, что может произойти.

  • $factory будет использовано как имя класса new;
  • будет ошибка парсинга;
  • будет использоваться вызов $factory->build(), значение которого вернет эта функция, в результате получится new;
  • будет использовано значение свойства $factory->build, чтобы сконструировать класс.

Давайте проверим последнюю идею. В классе $factory объявим ряд магических функций. Они будут писать, что мы делаем: вызываем свойство, обращаемся к методу, или вообще пытаемся вызвать invoke у объекта.

class Factory
"; return Foo::class;} public function _call($name, $arg) {echo "Calling method {$name}"; return Foo::class;} public function _invoke($name) {echo "Invoking {$name}"; return Foo::class;}
}

Правильный ответ: мы вызываем не метод, а свойство. После $factory->build находится параметр для конструктора, который мы передадим в этот класс.

Кстати, в этом фреймворке у меня реализована фишка, которая называется «перехват создание новых объектов» — можно «замокать» эту конструкцию.

Лазейка в парсере

Следующий пример касается самого PHP-парсера. Пробовали ли вы когда-нибудь вызывать функции или присваивать переменные внутри фигурных скобок?

Этот трюк мне попался в Twitter, он работает крайне нестандартно.

$result = ${'_' . !$_=getCallback()}(); $_=getCallback(); // string(5) "hello"
!$_=getCallback()}(); // bool(false) '_'.!$_=getCallback()}(); // string(1) "_"
${'_'.!$_=getCallback()}(); // string(5) "hello"

Сперва переменной с названием _ (подчеркивание) мы присваиваем значение выражения. У нас уже есть переменная, мы пытаемся логически инвертировать ее значение, и получаем false — строка кастуется как бы к true. Дальше это всё склеиваем в названии переменной, через которую потом обращаемся уже внутри фигурных скобок.

Это развлечение, которое позволяет нам почувствовать себя воодушевленно, необычно, сказать: «Что? Что такое магия? Так можно было?!»

Трюк #3. Ломаем правила

Мне нравится в PHP то, что можно ломать правила, которые все создают, пытаясь быть суперзащищенными. Есть конструкция под названием «запечатанный класс», у которого приватный конструктор. Ваша задача как ученика мага создать экземпляр этого класса.

Рассмотрим три варианта, как это можно сделать.

Обходной путь

Первый путь самый очевидный. Он должен быть знаком каждому разработчику — это стандартный API, который нам предлагает язык.

Конструкция newInstanceWithoutConstructor позволяет обойти ограничения языка на то, что конструктор приватный, и создать экземпляр класса в обход всех наших объявлений приватного конструктора.

Вариант рабочий, простой, не требует какого-то пояснения.

Замыкание

Второй вариант требует уже больше внимания и умения. Создается анонимная функция, которая затем биндится к скопу того класса.

Этим и пользуемся, вызывая new static из контекста нашего класса. Здесь мы находимся внутри класса и можем спокойно вызывать приватные методы.

Десериализация

Третий вариант самый передовой, на мой взгляд.

Если в определенном формате написать определенную строчку, подставить туда определенные значения — получится наш класс.

После десериализации получим наш instance.

doctrine/instantiator package

Магия часто становится документированным фреймворком или библиотекой — например, в doctrine/instantiator все это реализовано. Мы можем создавать любые объекты с любым кодом.

composer show doctrine/instantiator --all
name : doctrine/instantiator
descrip. : A small, lightweight utility to instantiate objects in PHP without invoking their constructors
keywords : constructor, instantiate
type : library
license : MIT License (MIT)

Трюк #4. Intercepting property access

Тучи сгущаются: класс секретный, свойства и конструктор приватные, и еще callback.

class Secret
{ private $secret = 42; private function _construct() { echo 'Secret is: ', $this->secret; } private function onPropAccess(string $name) { echo "Accessing property {$name}"; return 100500; }
}
// How to create a secret instance and intercept the secret value?

Наша задача, как волшебников, как-то вызвать callback.

Добавляем магический… getter

Добавим щепотку магии, чтобы все это заработало.

Он вызывает функцию, и пока что ничего страшного не произошло. Эта щепотка магии — магический getter. Но воспользуемся предыдущим трюком и создадим экземпляр этого объекта в обход приватного конструкта.

Теперь надо каким-то образом вызвать callback.

«Unset» внутри замыкания

Чтобы сделать это, создадим замыкание. Внутри замыкания, которое находится в скопе класса, удалим функцией unset() эту переменную.

unset позволяет временно исключить переменную, что позволит вызываться нашему магическому методу get.

Вызываем приватный конструктор

Так как у нас есть приватный конструктор, который выводит echo, то можно просто достать этот конструктор, сделать его доступным вызвав его.

Так наш секретный класс рассыпался в пух и прах.

Мы получили сообщение о том, что мы:

  • перехватили;
  • вернули что-то совершенно другое.

leedavis/altr-ego package

Много магии уже задокументировано. Пакет altr-ego как раз притворяется вашим компонентом.

composer show leedavis81/altr-ego --all
name : leedavis81/altr-ego
descrip. : Access an objects protected / private properties and methods
keywords : php, break scope
versions : dev-master, v1.0.2, v1.0.1, v1.0.0
type : library
license : MIT License (MIT)

Вы можете создать один свой объект и прицепить к нему второй. Это позволит проводить изменения объекта. Он будет изменяться послушно и выполнять все ваши пожелания.

Трюк #5. Immutable objects в PHP

Существуют ли в PHP Immutable object? Да, причем очень и очень давно.

namespace Magic
{ $object = (object) [ "\0Secret\0property" => 'test' ]; var_dump($object);
}

Только получать их надо интересным образом. Интересность в том, что мы создаем массив, у которого есть специальный ключ. Он начинается с конструкции \0 — это нулевой байт-символ, и после Secret мы тоже видим \0.

Если мы попытаемся кастануть какой-то объект к массиву, увидим те же самые ключи. Конструкция используется в PHP, чтобы объявить приватное свойство внутри класса. Он содержит в себе приватное свойство из класса Secret, которое равно test. У нас появится не что иное, как stdClass.

object(stdClass) [1] private 'property' (Secret) => string 'test' (length=4)

Единственная незадача — потом достать это свойство оттуда никак нельзя. Оно создается, но недоступно.

Поэтому решил, что пора бы запилить свое решение. Я подумал, что это довольно неудобно — у нас есть Immutable objects, но использовать его нельзя. Я использовал все мои знания и магию, которая имеется в PHP, чтобы создать конструкцию на базе всех наших магических трюков.

предыдущий трюк). Начнем с простого — создадим DTO и попытаемся в ней перехватить все свойства (см.

Они будут недоступы никакими методами: ни reflection, ни замыканиями, ни другой магией. Сохраним в надежном месте значения, которые оттуда захватим. Но возникает неопределенность — существует ли такое место в PHP, которое бы позволяло гарантированно сохранять какие-то переменные, чтобы туда никакой хитрый юный программист вообще не добрался?

Для этого у нас есть магические getters, магические методы isset, которые позволяют предоставить API. Предоставим магический метод, чтобы можно было прочитать это значение.

Вернемся к надежному месту и попробуем поискать.

  • Global variables отметаются — любой желающий может их поменять.
  • Public properties тоже не подходят.
  • Protected properties так себе, потому что дочерний класс проберется.
  • Private properties. Нет доверия, потому что через замыкание или через reflection его можно поменять.
  • Private static properties можно попробовать, но тоже ломается reflection.

Казалось бы, спрятать значения переменных некуда. Но нашлась волшебная штука — Static variables in functions — это переменные, которые находятся внутри функций.

Безопасное хранение значений

Я спросил на специальном канале Stack Overflow у Никиты Попова и Джея Воткинса об этом.

Можно ли из неё как-то достать, поменять? Это функция, внутри которой объявлена статичная переменная. Ответ — нельзя.

Мы нашли маленькую лазейку в потусторонний мир защищенных переменных и хотим использовать её.

Передача значений по ссылкам

Использовать будем нестандартно, как свойство объекта. Но нельзя передавать свойство, поэтому используем классическую передачу значений по ссылкам.

В любой вызов какой-то функции мы передаем значение переменной из Immutable object по ссылке во все наши вложенные методы. Получается, есть класс, в котором есть магический метод callStatic, и в нем объявлена переменная Static. Так мы как бы предоставляем контекст.

Сохраняем state

Посмотрим, как сохраняется состояние.

Для переданного объекта пользуемся функцией spl_object_id, которая для каждого экземпляра возвращает отдельный идентификатор. Всё довольно просто. Ничего особенного. Тот State, который мы уже достали из объекта, пытаемся сохранить туда.

Применяем состояние объекта

Здесь опять есть конструкция передачи значений по ссылке и unset свойства. Мы unset’им все текущие свойства, предварительно их сохранив в переменную State, и устанавливаем этот контекст для объекта. Больше объект не содержит никаких свойств, а только свой идентификатор, который объявляется с помощью spl_object_id и привязан к этому объекту, пока жив.

Получаем State

Дальше все просто.

Теперь никто и ничто не может поменять значение после того, как подключен этот Trait. На магический getter достаем этот контекст и вызываем из него наше свойство.

Все волшебные методы переопределены и реализуют неизменяемость объекта.

lisachenko/immutable-object

Как положено, все сразу оформляется в библиотеку и готово к использованию.

composer show /immutable-object --all
name : /immutable-object
descrip. : Immutable object library
keywords : versions : * dev-master
type : library
license : MIT License (MIT)

Выглядит библиотека довольно просто. Подключаем ее и создаем наш класс. У него разные свойства: приватные, протектные и public. Подключаем ImmutableTrait.

Оно действительно там сохраняется и даже выглядит, как настоящая DTO. После этого можно инициировать объект один раз — посмотреть его значение.

object (MagicObject) [3] public 'value' => int 200

Но если мы попытаемся ее поменять, например, так…

Мы не можем менять свойство, потому что оно Immutable. … то тут же сразу получим fatal exception. Как же так?

Если ввязаться в увлекательный челлендж и попытаться её дебажить, то получится следующее.

Как только в PHPStorm вы попытаетесь провалиться внутрь этого класса, он моментально остановит выполнение вашей команды. Это мой подарочек. Он будет предупреждать, что тут делать нечего. Не хочу, чтобы вы копались в этом коде — он слишком опасный.

Трюк #6. Обработка потоков

Рассмотрим конструкцию.

include 'php://filter/read=string.toupper/resource=magic.php';

Тут нечто волшебное: PHP-фильтр, read, в конце подключается какой-то файлик magic.php. Этот файлик выглядит довольно просто.

<?php echo 'Hello, world!'

Заметьте, что регистр разный. Однако, если «заинклюдим» файлик через нашу конструкцию, то получим вот это:

HELLO, WORLD!

Что произошло в этот момент? Использование конструкции PHP-фильтра в include позволяет подключить любой фильтр, в том числе и ваш, для анализа исходного кода. Вы управление всем, что находится в этом исходном коде. Можно убрать final из классов и методов, сделать свойства публичными — всё, что угодно можно провернуть через эту штуку.

Когда подключается ваш класс, он его анализирует и трансформирует в то, что будет выполняться. На этом базируется часть моего аспектного фреймворка.

Из коробки в PHP есть уже целая пачка готовых фильтров.

var_dump(stream_get_filters()); array (size=10)
0 => string 'zlib.*' (length=6)
1 => string 'bzip2.*' (length=7)
2 => string 'convert.iconv.*' (length=15)
3 => string ' string.rotl3' (length=12)
4 => string 'string.toupper' (length=14)
5 => string 'string.tolower' (length=14)
6 => string 'string.strip_tags' (length=17)
7 => string 'convert.*' (length=9)
8 => string 'consumed' (length=8)
9 => string 'dechunk' (length=7)

Они позволяют «зазиповать» контент, перевести его в верхний или нижний регистр.

Теперь перейдем к квинтэссенции всего, что я умею — к аспектно-ориентированному программированию. Основные трюки, которые я хотел показать, закончились.

Трюк #7. Аспектно-ориентированное программирование

Посмотрите на этот код и подумайте, хороший он или плохой с вашей точки зрения.

Он проверяет права доступа, выполняет логирование, создает юзера, персистит, пытается отловить exception. Кажется, код вполне адекватный.

Если посмотреть на всю эту лапшу, она повторяется в каждом нашем методе, и ценное здесь только то, что помечено зеленым.

Все остальное: «secondary concerns» или «crosscutting concerns» — сквозная функциональность.

Это плохо, потому что приходится повторяться. Обычный ООП не дает возможности взять копипастом все эти конструкции, убрать и куда-то вынести. Хотелось бы, чтобы код всегда выглядел чисто и аккуратно.

Чтобы он не содержал логирования (пусть оно как-то само применяется), и не содержал security.

Чтобы это все, включая логирование…

… и проверку безопасности,…

… выполнялось само.

И это возможно.

Глоссарий «Aspect»

Есть штука, которая называется «Aspect». Это простой пример, который проверяет права доступа. Благодаря ему вы можете видеть некоторую аннотацию, которую еще и подсвечивает плагин для PhpStorm. «Aspect» объявляет SQL-выражения, к каким точкам в коде применять данное условие. Ниже, например, мы хотим для всех публичных методов из класса UserService применить замыкание.

Замыкание получаем методом $invocation.

Это некоторая обертка поверх метода reflection, которая содержит еще аргументы.

Дальше в этом callback для каждого метода можно проверить необходимые права доступа, это называется «Advice».

Всего лишь одна строчка, а много полезного. Мы как бы говорим языку: «Уважаемый PHP, пожалуйста, примени этот метод перед каждым вызовом публичного метода из класса UserService».

Aspect vs Event Listener

Чтобы было понятнее, я сделал сравнение Aspect с Event Listener. Многие работают в Symfony и знают, что такое Event Dispatcher.

В случае Listener — это SubscrabingEvent под названием UserCreate, а в случае Aspect мы подписываемся на все вызовы публичных методов из UserService. Они похожи в том, что мы передаем какую-то зависимость, например, AutorizationChecker, и объявляем, куда применять в данном случае. При этом контент наполнения самого обработчика callback примерно одинаковый: мы просто что-то проверяем и соответственно реагируем.

Рассмотрим, как это все работает под капотом.

Первый этап, который требует аспектный фреймворк, это регистрация Aspects.

Чтобы это обработать, опять используется предыдущий трюк с PHP-фильтром. Второй этап.

Он это делает скрытно, но работать в продакшн будет хорошо, потому что интегрирован с OPcache. Это специальный компонент, который занимается преобразованием исходного кода.

Все интегрируется на уровне Composer. Третий этап. AOP, он начинает тесно общаться с Composer и договаривается о том, какие файлы откуда загружать. Как только устанавливается Go!

Это можно сделать буквально настройкой в среде. Поэтому можно грузить одновременно и версию кода без Aspects, и с Aspects.

Дальше начинается довольно сложная матчасть.

Чтобы сделать эту сложную работу, провернуть магический трюк, необходимо весь исходный код сперва проанализировать. PHP-Parser. Она позволяет провести токенизацию и построить абстрактное синтаксическое дерево всего кода. Хорошо, что есть такая замечательная библиотека Никиты Попова, как PHP-Parser.

Я создал еще одну библиотеку, которая называется goaop/parser-reflection. Четвертый этап.

Для меня это важно, потому что как только класс загружается в память, он оттуда никак не может быть выгружен, а хотелось бы узнать его структуру заранее. Она работает поверх AST-дерева и позволяет проводить рефлексию исходного кода, не загружая его в память.

Дальше принимаемся за разбор текущего файла.

Узнаем, какие в нем есть классы и как их изменить, благодаря тому что есть Aspect.

На выходе получается очень аккуратная и удобная штука в виде простого класса, которая декорирует ваш оригинальный класс.

Меняется не ваш оригинальный код — в кэше создается отдельная версия, в которой чуть-чуть меняется название класса, и потом наследуется от этого класса. Особенность фреймворка в том, что он подменяет ваш класс точно таким же классом с таким же именем, а оригинальный при этом переименовывает в кэше.

Поэтому можно отлавливать и финальные методы, и финальные классы. Наследование есть даже в том случае, если класс был финальным.

Она позволяет «замокать», в том числе и финальные методы, и статические методы. Поверх моего фреймворка работает библиотека Aspect MOCK. Все это работает под капотом.

В терминах аспектно-ориентированного программирования все называется joinPoint: каждый метод, обращение к свойству, перехват функции или создание. Наш переопределенный метод выглядит довольно просто — мы вызываем joinPoint.

Что дальше?

Дальше открываются невероятные возможности.

Весь AOP-движок будет прекомпилироваться на этапе загрузки приложения. OPcache preloading for AOP Core. Bootstrapping фреймворка будет занимать практически ничего, весь фреймворк будет находиться в памяти PHP. Это позволит снизить накладные расходы на его исполнение с 10 мс до нуля.

Следующее, что я буду делать, это изменять бинарно опкоды. FFI integration to modify binary opcodes. При использовании FFI все взлетит. Как только вы используете PHP-opcodes, в файловой системе генерируется файлик с названием .bin.

Внутри PHP есть глобальные переменные. Modifying PHP engine internal callbacks или модификация PHP-движка со стороны userland. Почему бы этим не воспользоваться. Если через FFI подключить PHP сам к себе в userland, то получим доступ к его внутренним свойствам, классам, структурам.

На этом магию остановим, пока не реализуем.

Трюк #8. goaop/framework

Это мой фреймворк, он есть на GitHub и у него там больше тысячи звезд.

composer show goaop/framework --all
name : goaop/framework
descrip. : Framework for aspect-oriented programming in PHP.
keywords : php, aop, library, aspect
versions : dev-master, 3.0.x-dev, 2.x-dev, 2.3.1, …
type : library
license : MIT License

Если вы боитесь магии, я создал помощника в виде плагина для PhpStorm.

Мы знаем, какие хотим методы обрабатывать, и как. Плагин удобен тем, что позволяет подсвечивать синтаксис Pointcuts. Также он предлагает навигацию — подсвечивает подсказки у методов, к тем методам, к которым мы хотим перейти.

Trick #9. Отложенные методы

Напоследок сделаем еще один трюк уже с использованием аспектного фреймворка. Посмотрим, как делать отложенные методы.

В таких случаях рекомендуется вынести выполнение этого кода до момента fastcgi_finish_request. Идея довольно проста: есть код, в котором какой-то из методов отрабатывает медленно. Мне это кажется неудобным, потому что все время приходится помнить, куда его засунуть, какой-то callback прикрутить — выглядит не нативно.

Что я предлагаю сделать и как это может работать?

Создаем доктриновскую аннотацию, которая называется Deffered, и помечаем, что она применяется для любого из методов.

После чего создаем Aspect, который говорит, что вокруг вызова методов, содержащих аннотацию Deffered, нужно выполнить следующий код.

Мы не даем выполниться этому коду. В свойство Aspect начинаем накапливать отложенные методы: сохраняем метод, который вызвался, объект, для которого был вызван данный callback, и аргументы, с которыми был вызван callback.

Мы в этот момент пообещаем, что когда-нибудь данный метод будет выполнен, а когда-нибудь потом мы закончим его выполнение, и получим решение. Поклонники React увидят, что тут должен быть promise.

Посмотрим, как всё это будет работать под капотом.

Как только запускается наше приложение, у нас есть callback, который говорит, что после того, как приложение завершится, надо вызвать callback onPhpTerminate. Регистрируем shutdown_function прямо в Aspect. И только теперь начнем по одному выполнять отложенные методы. В этом методе делаем fastcgi_finish_request и говорим: «Все, отправь, пожалуйста, клиенту весь контент, который создан».

Для примера представим, что у нас есть некоторый код и синхронный вызов sendPushNotification.

Допустим, какой-то плохой человек сделал его слишком медленным — он спит 2 с.

Мы не хотим, чтобы клиент, который делает запрос в наше приложение, еще 2 секунды ждал ответа.

Просто помечаем этот метод, как Deferred.

Где-то потом в фоновом режиме после завершения запроса, отправляется уведомление, что уже никак не мешает клиенту. Код моментально вылетает, клиент сразу получает ответ.

Надеюсь, что вам понравился урок магии и натолкнул вас на какие-то размышления. На этом все. Буду признателен, если оставите комментарии.

Мы готовим новую концепцию и принимаем доклады — подавайте заявки. Следующая профессиональная конференция PHP Russia 2020 пройдет в мае. Подписывайтесь на рассылку и telegram-канал, чтобы раньше других получить приглашение на PHP Russia 2020.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»