Новая PHP-конференция Пых.конф’25 — уже 19 сентября!
Конгресс-центр ЦМТ

UmbraUI — как я устал писать одни и те же компоненты и создал свой первый пакет для Laravel

Привет!

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

После очередного проекта, где я в тысячный раз писал <button class="px-4 py-2 bg-neutral-500...">, понял — хватит! Пора сделать что-то, что избавит меня (и вас) от этой рутины.

Так родился UmbraUI — мой первый пакет в принципе и по совместительству пакет UI компонентов для Laravel.

Почему именно так?

Просто устал каждый раз гуглить “accessibility для чекбоксов” и “почему у меня модалка не закрывается по Escape”…

Хотелось сделать библиотеку, которую я сам буду использовать в своих проектах. Без десятка зависимостей, без необходимости изучать новый фреймворк. Просто — поставил, написал <x-button>, и оно работает как надо.

Что получилось?

🎯 Только то, что реально нужно

Никакого раздувания. Взял самые ходовые компоненты из своих проектов и сделал их нормально. Кнопки, формы, карточки, табы — то, что используешь в 90% случаев.

🎨 Красиво из коробки

Всё на Tailwind CSS, вдохновлялся shadcn/ui и другими крутыми библиотеками. Но адаптировал под реалии Laravel-разработки.

🏠 Laravel-native подход

Никаких дополнительных зависимостей, сложных сборок или конфликтов. Чистые Blade компоненты, которые работают как родные Laravel элементы.

🌙 Тёмная тема

Потому что в 2025 году не поддерживать dark mode — это как не поддерживать мобильные устройства.

Что уже есть?

20+ компонентов, которые покрывают 95% потребностей:

Формы: Button, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Date Picker, Label, Field (с валидацией) и т. п.

UI элементы: Alert, Badge, Avatar, Card, Tabs, Accordion, Modal, Dropdown, Link и т. п.

Специальное: Table (с сортировкой), Toast (система уведомлений), Progress

Иконки: 5000 кастомизируемых иконок от Tabler icons

Как попробовать?

composer require ihxnnxs/umbra-ui

Для JS-функций (toast’ы и т. д.):

php artisan vendor:publish --tag=umbra-ui-assets

Мои планы

Сейчас пакет в активной разработке, но уже вполне рабочий. Планирую добавить:

  • Сайт для пакета
  • Chart
  • Modal
  • Header/sidebar
  • и многое другое

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

Попробуйте!

Пакет на GitHub: https://github.com/ihxnnxs/UmbraUI

Если пакет зайдёт — ставьте звёздочку на GitHub ⭐ Это реально мотивирует продолжать развитие проекта.

10

Быстрый экспорт данных в Excel-файлы с помощью FastExcelLaravel

Работа с Excel-файлами в проектах на Laravel — довольно частая задача: отчёты, выгрузка заказов, импорт товаров, интеграции с банками и маркетплейсами. Популярный пакет PhpSpreadsheet даёт огромные возможности, но при больших объёмах данных может быть чудовищно медленным и жутко прожорливым по памяти.

Альтернативой является библиотека avadim/fast-excel-laravel, построенная на основе библиотек FastExcelWriter и FastExcelReader. Она заточена под скорость и минимальное потребление памяти, что особенно важно при работе с десятками или сотнями тысяч строк.

Про FastExcelWriter я уже писал на Хабре. В этой статье опишу, использовать пакет для экспорта данных именно в Laravel-приложении.

Установка

Тут все стандартно:

composer require avadim/fast-excel-laravel

После установки сервис-провайдер и фасад будут зарегистрированы автоматически через Laravel Package Discovery. Если нужно, можно добавить фасад вручную:

'aliases' => [
    'FastExcel' => Avadim\FastExcelLaravel\Facades\Excel::class,
],

Экспорт моделей в Excel

Предположим, у нас есть таблица users, и мы хотим выгрузить всех пользователей в Excel. Самый простой и короткий вариант будет такой:

use Avadim\FastExcelLaravel\Excel;
use App\Models\User;

// Создаем Excel-таблицу с листом, который называется Users
$excel = \Excel::create(‘Users’);
// Экспортируем данные класса
$excel->sheet()->exportModel(User::class);
// Сохраняем в файл в storage
$excel->saveTo('app/public/users.xlsx');

Результат: будет создан файл users.xlsx со всеми пользователями.

Но в файле будут только сами данные, без заголовков. Если хотим, чтобы в первой строке были добавлены заголовки (поля модели), то нужно добавить вызов метода withHeadings(). Кроме того, в этом же методе можем указать, какие именно поля надо выводить в таблицу:

$excel->sheet()
->withHeadings(['id', 'name' => 'Имя'])
->exportModel(User::class);

В этом примере название поля 'id’ будет выводиться, как есть, а в заголовке столбца, куда будет записываться 'name’, будет указано 'Имя'.

Кстати, метод exportModel() позволяет выводить модели из таблиц, содержащих десятки и сотни тысяч записей без особых проблем, в отличие от PhpSpreadsheet.

Экспорт массивов и коллекций

Библиотека позволяет выводить в файл не только модели, но и просто массивы и коллекции Laravel. Для этого используется универсальный метод writeData()

$users = User::where('age', '>', 35)->get();
$excel->sheet()->writeData($users);

И вы можете передать в writeData() не только массив или коллекцию, но и генератор, который в цикле будет возвращать данные для для записи в таблицу:

$excel->sheet()->writeData(function () {
    foreach (User::cursor() as $user) {
        yield $user;
    }
});

Между прочим, если нам не нужно сохранять файл, а нужно сразу отдавать его пользователю, то вместо метода saveTo() нужно вызывать download()

Стилизация

Excel позволяет задавать стили для заголовков, для столбцов, для строк данных:

use Avadim\FastExcelLaravel\Excel;
use avadim\FastExcelWriter\Style;
use App\Models\User;

// Задаем стили для столбцов
$excel->sheet()
    ->setColStyle('B', [Style::WIDTH => 20])
    ->setColStyle('C', [Style::FONT_SIZE => 12, Style::FONT_COLOR => '#f00']);

$excel->sheet()->withHeadings()
    ->applyFontStyleBold() // шрифт заголовка
    ->applyBorder('thin') // границы заголовка
    ->exportModel(User::class);

Подробнее о том, как задаются стили, можно почитать в документации к пакету avadim/fast-excel-writer: https://github.com/aVadim483/fast-excel-writer/blob/master/docs/04-styles.md

И вообще, если вам нужен контроль за формированием Excel-таблицы на более низком уровне (выводить данные по строкам и даже по ячейкам, стилизовать каждую ячейку вывода, добавлять графики, условное форматирование и т.д.), то обратитесь к документации пакета avadim/fast-excel-writer – все его возможности применимы и здесь.

Каким мог бы быть Laravel WebServer, если бы он работал через очередь?

Привет! Сегодня я хочу поделиться с вами одной интересной идеей — а что если Laravel WebServer принимал бы все HTTP-запросы не напрямую, а через обычную очередь задач Laravel Queue? Вместо привычных PHP-FPM, Swoole, RoadRunner или FrankenPHP — весь запрос обрабатывался бы как задача в очереди.

Звучит странно? Давайте разбираться, как это могло бы работать и что из этого получилось бы.

Первое, что нам нужно — это получать HTTP-запросы «голыми» — простым текстом, как они приходят от браузера:

GET / HTTP/1.1
Host: example.com

Для этого в Laravel мы можем использовать ReactPHP — асинхронную библиотеку, которая умеет слушать TCP-сокеты.

Создадим Artisan-команду app:server и в ней запустим сервер на порту 8080:

class ServerCommand extends Command
{
    protected $signature = 'app:server';

    public function handle(): void
    {
        $loop = Loop::get();

        $server = new SocketServer('127.0.0.1:8080', [], $loop);

        $server->on('connection', function (ConnectionInterface $connection): void {
            $connection->on('data', function (string $data) use ($connection): void {
                // Пока просто посмотрим, что пришло
                dd($data);
            });
        });

        $this->info('Server started on 127.0.0.1:8080');
        $loop->run();
    }
}

Запускаем php artisan app:server, заходим в браузер по адресу http://127.0.0.1:8080/ — и видим, что сервер получает сырые данные запроса. Отлично, базовая часть работает!

Теперь основая идея — каждый пришедший запрос превратить в задачу (Job) и отправить в очередь на обработку:

$connection->on('data', function (string $data) {
    HttpHandler::dispatch($data);
});

Но тут возникает проблема — обьект запроса Request::capture() работает с глобальными переменными ($_GET, $_POST и т.д.), а не с сырым HTTP-текстом. Значит нам нужно самим разобрать HTTP-запрос из текста в объект Request. Для целей эксперимента я сделал простой парсер, но он нам не интересен, по этому опустим его листинг :)

Помимо этого наш, класс должен обратно обрабатывать обьект Response в текст. А в самой Job-е мы вызываем Laravel, как будто это обычный HTTP-запрос:

class HttpHandler implements ShouldQueue
{
    use Queueable;

    public function __construct(protected string $http) {}

    public function handle(): void
    {
        $bridge = new HttpRawRequestParser($this->http);
        $illuminateRequest = $bridge->toIlluminateRequest();

        $response = app()->handle($illuminateRequest);

        $rawResponse = $bridge->fromIlluminateResponse($response);

        // Здесь надо отправить rawResponse обратно клиенту — как? Об этом дальше.
    }
}

Чтобы отдать результат запроса обратно клиенту, нам нужно как-то связать запрос и ответ. Для этого, почему бы не запускать сразу два ReactPHP-сервера:

  • Первый слушает клиентов на 8080 и отправляет запросы в очередь, запоминая соединение и уникальный ID запроса.
  • Второй слушает 8088 и получает от очереди обработанные ответы, связывает их по ID с клиентским соединением и отдает клиенту ответ.
class ServerCommand extends Command
{
    protected array $clients = [];

    public function handle(): void
    {
        $loop = Loop::get();

        $this->startClientServer($loop);
        $this->startJobServer($loop);

        $this->info('Servers started on 8080 and 8088');
        $loop->run();
    }

    protected function startClientServer(LoopInterface $loop): void
    {
        $server = new SocketServer('127.0.0.1:8080', [], $loop);

        $server->on('connection', function (ConnectionInterface $connection): void {
            $connection->on('data', function (string $data) use ($connection): void {
                $requestId = Str::uuid()->toString();
                $this->clients[$requestId] = $connection;

                HttpHandler::dispatch($data, $requestId);
            });
        });
    }

    protected function startJobServer(LoopInterface $loop): void
    {
        $server = new SocketServer('127.0.0.1:8088', [], $loop);

        $server->on('connection', function (ConnectionInterface $connection) {
            $buffer = '';

            $connection->on('data', function (string $data) use (&$buffer, $connection) {
                $buffer .= $data;

                if (substr($data, -1) !== '}') return;

                $payload = json_decode($buffer, true);
                if (!$payload) return;

                $requestId = $payload['requestId'] ?? null;
                $response = $payload['response'] ?? null;

                if ($requestId && isset($this->clients[$requestId])) {
                    $clientConnection = $this->clients[$requestId];
                    $clientConnection->end($response);
                    unset($this->clients[$requestId]);
                }
            });
        });
    }
}

А внутри джобы HttpHandler после обработки запроса отправляем JSON с requestId и ответом на джобсервер:

public function handle(): void
{
    // ...

    $payload = json_encode([
        'requestId' => $this->requestId,
        'response' => $rawResponse,
    ]);

    $socket = stream_socket_client("tcp://127.0.0.1:8088", $errno, $errstr, 2);
    if (!$socket) {
        Log::warning("Socket error: $errstr ($errno)");
        return;
    }

    fwrite($socket, $payload);
    fflush($socket);
    fclose($socket);
}

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

  • ReactPHP-сервер принимает HTTP-запрос. Создает уникальный ID, запоминает соединение, кладет в очередь Job с запросом и ID.
  • Job обрабатывает Laravel запрос и отправляет результат обратно через TCP-сокет на второй сервер.
  • Второй сервер находит соединение по ID и отдает клиенту ответ.

Попробовав нагрузить сервер с помощью ab (ApacheBench), и результаты оказались не самыми быстрыми… Хорошо, а если запусить сразу несколько фоновых процессов, через horizon, он будет автоматически скейлить их количество до настроенных значения (Для примера взял 10).

Переключение на Horizon с автоскейлингом воркеров улучшило ситуацию, но задержка всё ещё ощущается. Это потому, что очередь Laravel — это не realtime-механизм. Фоновый процесс опрашивает Redis с задержкой. Если убрать паузы — будет постоянная нагрузка и расход ресурсов. (Конечно с автоскейллинг позаботиться, но один процесс все равно будет постоянно опрашивать).

➜  ~ ab -c 100 -n 100 http://127.0.0.1:8080/
This is ApacheBench, Version 2.3 <$Revision: 1913912 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient).....done


Server Software:        
Server Hostname:        127.0.0.1
Server Port:            8080

Document Path:          /
Document Length:        80819 bytes

Concurrency Level:      100
Time taken for tests:   0.518 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      8285000 bytes
HTML transferred:       8081900 bytes
Requests per second:    193.08 [#/sec] (mean)
Time per request:       517.923 [ms] (mean)
Time per request:       5.179 [ms] (mean, across all concurrent requests)
Transfer rate:          15621.67 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   0.3      1       2
Processing:     8  288 131.1    289     509
Waiting:        6  288 131.2    289     509
Total:          8  289 130.9    290     510

Percentage of the requests served within a certain time (ms)
  50%    290
  66%    364
  75%    405
  80%    427
  90%    471
  95%    492
  98%    506
  99%    510
 100%    510 (longest request)

Для сравнения, Laravel Octane (Так же с 10 воркерами) показывает:

~ ab -c 100 -n 100 http://127.0.0.1:8000/
This is ApacheBench, Version 2.3 <$Revision: 1913912 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient).....done


Server Software:        
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /
Document Length:        80819 bytes

Concurrency Level:      100
Time taken for tests:   0.503 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      8189700 bytes
HTML transferred:       8081900 bytes
Requests per second:    198.98 [#/sec] (mean)
Time per request:       502.566 [ms] (mean)
Time per request:       5.026 [ms] (mean, across all concurrent requests)
Transfer rate:          15913.84 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    4   1.0      4       6
Processing:    21  212  69.6    231     328
Waiting:       21  212  69.7    231     327
Total:         27  216  68.9    235     333

Percentage of the requests served within a certain time (ms)
  50%    235
  66%    258
  75%    266
  80%    274
  90%    288
  95%    298
  98%    325
  99%    333
 100%    333 (longest request)