В Laravel 13.27 добавлена настройка для отдельных подключений, которая скрывает значения привязок запроса из сообщений об исключениях, семейство методов  whereBinary()  для побайтового сравнения в MySQL и MariaDB, метод  refreshForUpdate() , который перезагружает модель Eloquent с пессимистической блокировкой, и многое другое:

  •  mask_bindings_in_exception_messages  оставляет заполнители  ?  в сообщениях  QueryException 
  •  whereBinary() ,  orWhereBinary() ,  whereNotBinary()  и  orWhereNotBinary() 
  •  refreshForUpdate()  обновляет модель с применённым  lockForUpdate() 
  • Фасад  Cloud  для проверок Laravel Cloud и управляемого подключения очередей
  • Методы очереди  totalPendingSize() ,  totalDelayedSize()  и  totalReservedSize() 
  • Запросы векторного расстояния теперь работают в MariaDB
  • Опции DSN для Postgres keepalive и общий кэш учётных данных AWS для SQS
  • Исправления для обработки ввода и усиления валидации

#Что нового

#Маскирование привязок запроса в сообщениях об исключениях

 QueryException  подставляет привязки (bindings) прямо в своё сообщение, поэтому при сбойном insert каждое привязанное значение попадает в  getMessage() . Затем это сообщение уходит везде, куда попадают исключения: в таблицу  failed_jobs , лог-файлы и спаны APM. В итоге логируются email-адреса, имена и другие персональные данные.

Новый ключ конфигурации для конкретного подключения останавливает интерполяцию и оставляет заполнители  ?  на своих местах:

 'mysql' => [
    'driver' => 'mysql',
    // ...
    'mask_bindings_in_exception_messages' => env('DB_MASK_BINDINGS', false),
], 
 // default
SQLSTATE[23000]: ... SQL: insert into `users` (`email`) values (foo@example.com))
 
// masked
SQLSTATE[23000]: ... SQL: insert into `users` (`email`) values (?)) 

Этот ключ добавлен в  config/database.php , поэтому приложения, которые никогда не публиковали этот файл конфигурации, могут включить его с помощью  DB_MASK_BINDINGS=true . По умолчанию установлено значение  false , при этом меняется только текст сообщения; метод  getBindings()  по-прежнему возвращает значения. Подробнее о маскировании привязок запроса в сообщениях об исключениях Laravel можно прочитать здесь.

Внесено разработчиком @LauJosefsen в PR #61326.

# whereBinary()  для сравнений с учётом регистра

Сопоставления (collations) в MySQL по умолчанию сравнивают строки без учёта регистра, поэтому  where('name', 'John')  также совпадёт с  john  и  JOHN . Чтобы получить точное побайтовое сравнение, приходилось использовать сырой SQL:

 DB::table('queues')->whereRaw('name = BINARY ?', [$queueName])->first(); 

 whereBinary()  делает то же самое через конструктор запросов (query builder), наряду с  orWhereBinary() ,  whereNotBinary()  и  orWhereNotBinary() :

 DB::table('queues')->whereBinary('name', $queueName)->first();
// select * from `queues` where `name` = binary ?
 
DB::table('queues')->whereNotBinary('name', $queueName)->get();
// select * from `queues` where `name` != binary ? 

MariaDB наследует грамматику MySQL, поэтому там это тоже работает. Postgres, SQLite и SQL Server выбрасывают  RuntimeException , что соответствует тому, как  whereLike()  обрабатывает чувствительные к регистру поиски в СУБД, которые и так сравнивают с учётом регистра.

Внесено разработчиком @xiCO2k в PR #61261.

# refreshForUpdate() 

Модели обычно извлекаются до начала транзакции — через связывание моделей с маршрутами (route model binding) или из полезной нагрузки задачи (job payload). Установка пессимистической блокировки на такую модель означала отбрасывание этого экземпляра и повторный запрос по первичному ключу:

 DB::transaction(function () use ($product) {
    $product = Product::query()
        ->lockForUpdate()
        ->findOrFail($product->getKey());
 
    $product->decrement('stock');
}); 

refreshForUpdate работает аналогично  refresh() , но применяет  lockForUpdate()  к повторному запросу, поэтому уже имеющийся экземпляр обновляется прямо на месте и остаётся пригодным для использования до конца транзакции:

 DB::transaction(function () use ($product) {
    $product->refreshForUpdate();
 
    if ($product->stock === 0) {
        throw new RuntimeException('The product is out of stock.');
    }
 
    $product->decrement('stock');
}); 

Внесено разработчиком @stevebauman в PR #61247. Блокировка удерживается только на время транзакции, поэтому вызов должен находиться внутри неё; это устраняет состояние гонки данных между чтением модели и записью в неё обратно.

#Фасад  Cloud 

Чтобы ответить на вопросы «находимся ли мы в Laravel Cloud и используем ли управляемые очереди?», ранее приходилось комбинировать хелпер  laravel_cloud()  с проверками драйверов. Фасад  Cloud  объединяет их в три метода:

 use Illuminate\Support\Facades\Cloud;
 
Cloud::hosted();            // running on Laravel Cloud?
Cloud::usesManagedQueues(); // is the cloud queue connection configured?
Cloud::queue();             // the managed queue connection itself 

 Cloud::queue()  выбрасывает  RuntimeException , когда управляемые очереди не настроены, поэтому он используется в паре с  usesManagedQueues() , а не заменяет его. Фасад не зарегистрирован в псевдонимах по умолчанию, поэтому он не будет конфликтовать с существующим классом  Cloud  в приложении. Существовавший ранее класс загрузки  Cloud  в рамках этого изменения был переименован в  CloudBootstrapper .

Внесено разработчиком @jackbayliss в PR #61275. Ранее в этом году для Laravel Cloud также появился CLI-инструмент.

#Общие итоги по всем очередям

Существующие методы  pendingSize() ,  delayedSize()  и  reservedSize()  принимают одно имя очереди, поэтому для панели управления, охватывающей несколько очередей, приходилось суммировать их вручную. Три аналогичных метода с префиксом  total  делают это за один вызов:

 Queue::totalPendingSize();
Queue::totalDelayedSize();
Queue::totalReservedSize(); 

Они суммируют значения по всем очередям, о которых известно подключению, и, в отличие от методов  allXJobs() , выполняют только подсчёт, поэтому полезная нагрузка задач не декодируется. Реализовано для драйверов Redis, database, failover и fake.

Внесено разработчиком @jackbayliss в PR #61231.

#Запросы векторного расстояния в MariaDB

Ранее методы  whereVectorSimilarTo() ,  whereVectorDistanceLessThan() ,  orderByVectorDistance()  и  selectVectorDistance()  поддерживали только Postgres, так как проверка драйвера выполнялась через  instanceof PostgresConnection , а оператор pgvector  <=>  был прописан непосредственно в конструкторе запросов.

Оба моменты перенесены в грамматику, следуя паттерну, который уже используется для  compileRandom()  и  supportsSavepoints() . В MariaDB 11.7+ есть нативный тип колонок  VECTOR  и функции расстояния, поэтому её грамматика компилируется в  vec_distance_cosine() , и поддержка  typeVector()  со стороны схемы наконец получила соответствующую реализацию со стороны запросов. Обычный MySQL по-прежнему выбрасывает исключение, так как  VECTOR_DISTANCE()  существует только в MySQL HeatWave.

Внесено разработчиком @Rhaima96 в PR #61250.

#Улучшения обработки ввода и усиление валидации

@KIKOmanasijev внёс ряд исправлений, связанных с тем, как Laravel читает ключи и сравнивает значения.

Вызов  Request::merge(['*' => 226])  раньше очищал весь массив входных данных вместо добавления ключа  * , поскольку редюсер прогонял ключи через  data_set() , который трактует  *  как wildcard. В  Uri::withQuery()  была та же ошибка уровнем выше, превращавшая  ?role=user&tenant=10  в  ?role=admin&tenant=admin . Теперь оба используют  Arr::set() , что сохраняет работу точечной нотации для настоящих вложенных слияний, но сохраняет  *  буквально (#61309, #61312).

Отклонение неизвестных полей, добавленное в Laravel 13, разворачивало входной массив с помощью  Arr::dot() , что создаёт одинаковый ключ для вложенного поля  profile[name]  и буквального ключа  "profile.name" . Валидатор экранирует буквальные точки как  profile\.name , из-за чего они сравнивали ключи в разных нотациях: буквальный ключ с точкой проходил проверку на неизвестное поле, затем для него не запускалось ни одно правило, и  validated()  возвращался пустым, пока сырое значение находилось в  $request->all() . Теперь ключи ввода разворачиваются с тем же экранированием, что и ключи правил (#61313).

Два правила валидации всё ещё использовали нестрогие сравнения.  in_array  сопоставлял  "1e0"  с  "1"  и  "0e123456789"  с  "0" , а  doesnt_contain  браковал массив, содержащий  "0e123" , как будто тот содержал запрещённый  "0" . Оба правила теперь используют строгое сравнение (#61319, #61318), вслед за аналогичным исправлением, ранее применённым к правилу  in . Эквивалентное изменение для  contains  было откатано до релиза (#61330).

Наконец,  MaintenanceModeBypassCookie::isValid()  проверял, что  mac  куки установлен, но не проверял, что это строка, поэтому cookie  laravel_maintenance , содержащая массив, передавала этот массив в  hash_equals()  и превращала ожидаемую ошибку 503 в неаутентифицированную 500. Проверка  isset()  была заменена на  is_string()  (#61314).

#Другие исправления и улучшения

  • Подключения Postgres принимают ключи конфигурации  keepalives ,  keepalives_idle ,  keepalives_interval  и  keepalives_count , которые попадают в DSN вместо того, чтобы отбрасываться, благодаря чему долгоживущее подключение воркера не завершается тихо из-за таймаута неактивности фаервола (#61307)
  • Подключения SQS могут делиться закэшированными учётными данными AWS между процессами через опцию  credentials_cache , заменяя получение учётных данных на каждый воркер PHP-FPM одним получением на ротацию. Laravel Cloud включает её по умолчанию для управляемых очередей (#61321)
  • Вернулись методы  orWhereKey()  и  orWhereKeyNot() . Первая попытка вышла в 13.26 и была отозвана в 13.26.1 из-за изменения сигнатур  whereKey() , что критически ломало подклассы Builder, переопределяющие их. Эта версия делегирует вызовы через вложенную группу where и оставляет существующие методы нетронутыми (#61242)
  • Методы  move()  и  copy()  на сквозном диске (read-through disk) теперь потоково читают из резервного хранилища, когда основное ещё не переместило файл, вместо возврата  false  для файла, который  exists()  определяет как существующий (#61272)
  • Задержанная (debounced) задача больше не оставляет за собой метку времени  maxWait  после выполнения, из-за чего первый запуск следующей пачки полностью пропускал задержку (#61281)
  • Запушенная внутри транзакции задача очереди при откате транзакции больше не освобождает принудительно уникальную блокировку, которую она никогда не захватывала (#61234)
  • Замыкания ограничений жадной загрузки (eager-load) снова стали  static , разрушая циклическую ссылку на  Builder , которая удерживала билдеры и их деревья загруженных отношений в памяти до запуска сборщика циклических ссылок, что наиболее критично для воркеров очередей и Octane (#61264)
  • Вызов  Process::quietly()->run(...)->throw()  выбрасывает  ProcessFailedException  вместо  LogicException  об отключённом выводе (#61227), а  FakeInvokedProcess  получил методы  stop()  и  ensureNotTimedOut() , чтобы фейковые процессы можно было останавливать в тестах (#61266), оба изменения от @xurshudyan
  • Трейт  InteractsWithTestCaseLifecycle::flushState()  даёт приложениям документированный хук для сброса собственного статического состояния между тестами вместо переопределения внутреннего метода  tearDownTheTestEnvironment()  (#61288)
  • Изображения, созданные из потока, больше не падают при повторном чтении, так как ленивый загрузчик (lazy loader) теперь разрешается один раз и делится результатом с клонами.  fromUrl()  по той же причине перестаёт отправлять новый HTTP-запрос на каждое чтение (#61305), а также  fromUrl()  выбрасывает исключение при неуспешных ответах вместо того, чтобы обрабатывать тело 404-й ошибки как данные изображения (#61254)
  • Методы  PhpRedisConnection::mget()  и  hmget()  защищены от возврата  false , который phpredis выдаёт при отклонённой команде, что ранее приводило к  TypeError  из  array_map()  при ошибке CROSSSLOT (#61267)
  • Исключение из  Brick\Math  больше не выходит за пределы правил числового сравнения. Защитные блоки в  gt  и  gte  перехватывали не тот класс  MathException , а в  lt  и  lte  защиты не было вовсе (#61332)
  •  Container::scoped()  больше не записывает один и тот же абстракт дважды в  $scopedInstances  (#61251),  Route::name('x')->post(...)  сохраняет свое имя для экшенов контроллера в виде массива (#61285), а  maxRelationshipDepth(0)  отбрасывает вложенные включения (includes) вместо того, чтобы брать сегменты с конца массива (#61297)

 Ссылки