src/Shared/Infrastructure/EventSubscriber/AdminEditFormErrorSubscriber.php line 53

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Shared\Infrastructure\EventSubscriber;
  4. use App\Domain\Entity\Expediente;
  5. use App\Shared\Infrastructure\Form\Extension\FormInvalidBuffer;
  6. use App\Shared\Infrastructure\Observability\ExpedienteTag;
  7. use App\Shared\Infrastructure\Observability\ObservabilityLogger;
  8. use Doctrine\ORM\EntityManagerInterface;
  9. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  10. use Symfony\Component\HttpFoundation\RequestStack;
  11. use Symfony\Component\HttpKernel\Event\ResponseEvent;
  12. use Symfony\Component\HttpKernel\KernelEvents;
  13. use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
  14. use Throwable;
  15. /**
  16.  * FIX-260521-003-ter — [OBS-ADMIN-EDIT-FORMERR] captura señales de fallo de
  17.  * validación en POST a admin expediente edit (caso A4 Subsanación arbitraje
  18.  * donde el servidor responde 200 pero el form se re-renderiza con errores
  19.  * y el usuario ve "el mismo mensaje" sin que aparezca exception en log).
  20.  *
  21.  * Heurística: para rutas admin edit con POST y status 200 (no redirect a
  22.  * list), volcar al canal OBS información estructurada del request +
  23.  * mensajes de flash bag de Sonata Admin (sonata_flash_error si existe).
  24.  * No accede al Form (no está en Response event); solo señales externas.
  25.  */
  26. final class AdminEditFormErrorSubscriber implements EventSubscriberInterface
  27. {
  28.     private const WATCHED_ROUTES = [
  29.         'admin_app_domain_expediente_edit',
  30.         'admin_app_domain_tramite_edit',
  31.     ];
  32.     public function __construct(
  33.         private ObservabilityLogger $observabilityLogger,
  34.         private ?RequestStack $requestStack null,
  35.         private ?TokenStorageInterface $tokenStorage null,
  36.         private ?EntityManagerInterface $em null,
  37.         private ?FormInvalidBuffer $formInvalidBuffer null
  38.     ) {
  39.     }
  40.     public static function getSubscribedEvents(): array
  41.     {
  42.         return [
  43.             KernelEvents::RESPONSE => ['onKernelResponse', -512],
  44.         ];
  45.     }
  46.     public function onKernelResponse(ResponseEvent $event): void
  47.     {
  48.         try {
  49.             if (!$event->isMainRequest()) {
  50.                 return;
  51.             }
  52.             $request $event->getRequest();
  53.             if ($request->getMethod() !== 'POST') {
  54.                 return;
  55.             }
  56.             $route = (string) $request->attributes->get('_route');
  57.             if (!in_array($routeself::WATCHED_ROUTEStrue)) {
  58.                 return;
  59.             }
  60.             $status $event->getResponse()->getStatusCode();
  61.             if ($status !== 200) {
  62.                 return;
  63.             }
  64.             $flashErrors $this->extractFlashErrors();
  65.             $body $event->getResponse()->getContent() ?: '';
  66.             // FIX-260623 — resolver Expediente una vez para enriquecer OBS con
  67.             // expediente_codigo. Cacheado en $expediente para reusar despues en
  68.             // findRequiredEmptyFields y evitar segundo find(). NUNCA rompe.
  69.             $expedienteId $request->attributes->get('id');
  70.             $expediente   null;
  71.             if ($this->em !== null && $expedienteId !== null && $expedienteId !== '') {
  72.                 try {
  73.                     $expediente $this->em->getRepository(Expediente::class)->find((string) $expedienteId);
  74.                 } catch (Throwable $e) {
  75.                     $expediente null;
  76.                 }
  77.             }
  78.             $context ExpedienteTag::for($expediente) + [
  79.                 'route'         => $route,
  80.                 'path'          => $request->getPathInfo(),
  81.                 'expediente_id' => $expedienteId,
  82.                 'tramite_key'   => $request->query->get('tramite'),
  83.                 'uniqid'        => $request->query->get('uniqid'),
  84.                 'user'          => $this->resolveUser(),
  85.                 'post_keys_tree'=> $this->keyTree($request->request->all(), 4),
  86.                 'has_files'     => count($request->files->all()) > 0,
  87.                 'flash_errors'  => $flashErrors,
  88.                 'response_size' => strlen($body),
  89.                 'form_errors'   => $this->scanInlineFormErrors($body),
  90.             ];
  91.             $this->observabilityLogger->safeLog(
  92.                 'warning',
  93.                 '[OBS-ADMIN-EDIT-FORMERR] post_returned_200_no_redirect',
  94.                 $context
  95.             );
  96.             // FIX-260623-001 — Flash explicativo cuando hay POST con botón submit
  97.             // pero la transición no completa (200 no redirect). Cubre 2 casos:
  98.             //  (a) submit-button no-fire (handler no disparó porque botón no es
  99.             //      identificado como submitter del form HTML).
  100.             //  (b) required-empty bloqueando el save de Sonata.
  101.             // En ambos el toast genérico "Se ha producido un error" no informa
  102.             // qué falta — pintamos los campos required-vacíos detectados.
  103.             //
  104.             // FIX-260625-002 — el heurístico findClickedSubmitButton solo detecta
  105.             // botones cuyo padre contiene "boton" en el nombre. Eso EXCLUYE casos
  106.             // legítimos: A2 tiene su submit en `iniciosolicitudarbitraje`
  107.             // (parent NO contiene "boton") → el flash nunca se emitía aunque hubiera
  108.             // errores reales del root form (FormInvalidBuffer poblado por la
  109.             // extension FIX-260623-003). Resultado en cliente: solo veía
  110.             // "No puede ser vacio" inline sin breadcrumb del campo concreto.
  111.             // Solución: invocar emitExplanatoryFlash SIEMPRE que haya errores en
  112.             // el buffer (independiente de clicked button); usar clicked como
  113.             // ENRIQUECIMIENTO (button_name humano) cuando esté disponible. Si no
  114.             // hay clicked y el buffer está vacío, el flash final cae al genérico.
  115.             $clicked $this->findClickedSubmitButton($request->request->all());
  116.             $missing = [];
  117.             if ($clicked !== null) {
  118.                 $missing $this->findRequiredEmptyFields(
  119.                     (string) $context['expediente_id'],
  120.                     $clicked,
  121.                     $request->request->all(),
  122.                     $expediente
  123.                 );
  124.                 $this->observabilityLogger->safeLog(
  125.                     'warning',
  126.                     '[OBS-HANDLER-NO-FIRE] submit_button_post_transition_did_not_complete',
  127.                     $context + [
  128.                         'clicked_button'   => $clicked,
  129.                         'missing_required' => $missing,
  130.                     ]
  131.                 );
  132.             }
  133.             // FIX-260625-002 — emit flash siempre que tengamos algo útil que decir:
  134.             //  - clicked button detectado, O
  135.             //  - root form errors en el buffer (FormInvalidBuffer poblado), O
  136.             //  - heurística de missing required del schema.
  137.             // Sin condiciones extra (un flash genérico es mejor que ningún flash
  138.             // cuando "No puede ser vacio" aparece inline sin breadcrumb).
  139.             $hasBufferErrors = !empty($this->formInvalidBuffer?->getErrors() ?? []);
  140.             if ($clicked !== null || $hasBufferErrors) {
  141.                 $this->emitExplanatoryFlash($clicked$missing);
  142.                 // FIX-260625-003 — extender OBS con métricas de resolución de
  143.                 // path real via cause (ConstraintViolation::getPropertyPath()).
  144.                 $bufferErrors $this->formInvalidBuffer?->getErrors() ?? [];
  145.                 $resolvedCount 0;
  146.                 $sampleResolved = [];
  147.                 foreach ($bufferErrors as $be) {
  148.                     if (!empty($be['real_path_via_cause'])) {
  149.                         $resolvedCount++;
  150.                         if (count($sampleResolved) < 3) {
  151.                             $sampleResolved[] = (string) ($be['path'] ?? '');
  152.                         }
  153.                     }
  154.                 }
  155.                 // OBS: trazabilidad del path que disparó el flash.
  156.                 $this->observabilityLogger->safeLog(
  157.                     'info',
  158.                     '[OBS-ADMIN-EDIT-FORMERR] explanatory_flash_emitted',
  159.                     $context + [
  160.                         'clicked_detected'        => $clicked !== null,
  161.                         'buffer_errors_count'     => count($bufferErrors),
  162.                         'missing_schema'          => count($missing),
  163.                         'paths_resolved_via_cause'=> $resolvedCount,
  164.                         'paths_unresolved_count'  => max(0count($bufferErrors) - $resolvedCount),
  165.                         'field_paths_sample'      => $sampleResolved,
  166.                     ]
  167.                 );
  168.             }
  169.         } catch (Throwable $e) {
  170.             // swallow.
  171.         }
  172.     }
  173.     /**
  174.      * Localiza dentro del POST un botón submit "clickeado" (valor truthy en
  175.      * una sub-key cuyo nombre contiene "boton" o cuyo padre contiene
  176.      * "botonarbitraje"). Devuelve el path como lista de keys.
  177.      *
  178.      * @return array{path: array<int,string>, button_name: string}|null
  179.      */
  180.     private function findClickedSubmitButton(array $post): ?array
  181.     {
  182.         $found null;
  183.         $walk = function ($arr, array $path) use (&$walk, &$found) {
  184.             if ($found !== null) return;
  185.             foreach ($arr as $k => $v) {
  186.                 $nextPath $path;
  187.                 $nextPath[] = (string) $k;
  188.                 if (is_array($v)) {
  189.                     $walk($v$nextPath);
  190.                     if ($found !== null) return;
  191.                 } else {
  192.                     // Botones submit en JsonFieldType se nombran como hijos
  193.                     // de "botonarbitraje*", "boton*", "botones*". Cuando se
  194.                     // pulsan, Sonata incluye un valor scalar (vacío o el text).
  195.                     $parentKey count($path) > ? (string) $path[count($path) - 1] : '';
  196.                     $isInBotonContainer stripos($parentKey'boton') !== false;
  197.                     if ($isInBotonContainer && is_string($v)) {
  198.                         $found = [
  199.                             'path'        => $nextPath,
  200.                             'button_name' => (string) $k,
  201.                         ];
  202.                         return;
  203.                     }
  204.                 }
  205.             }
  206.         };
  207.         $walk($post, []);
  208.         return $found;
  209.     }
  210.     /**
  211.      * Carga el tramite del expediente cuyo subform contiene el botón clickado,
  212.      * y devuelve la lista de campos required-vacíos (labels humanizados).
  213.      *
  214.      * @param array{path: array<int,string>, button_name: string} $clicked
  215.      * @return array<int,string>
  216.      */
  217.     private function findRequiredEmptyFields(string $expedienteId, array $clicked, array $post, ?Expediente $expediente null): array
  218.     {
  219.         if ($this->em === null) return [];
  220.         try {
  221.             // Path típico: [uniqid, 'tramites', N, 'form', SubformKey, 'botonarbitraje...', button_name]
  222.             $path $clicked['path'];
  223.             $tIdx null;
  224.             for ($i 0$i count($path) - 1$i++) {
  225.                 if ($path[$i] === 'tramites' && isset($path[$i 1]) && ctype_digit((string) $path[$i 1])) {
  226.                     $tIdx = (int) $path[$i 1];
  227.                     break;
  228.                 }
  229.             }
  230.             if ($tIdx === null) return [];
  231.             // FIX-260623 — reusar $expediente ya cargado en onKernelResponse si
  232.             // viene; fallback al find() previo para invocaciones aisladas.
  233.             if ($expediente === null) {
  234.                 $expediente $this->em->getRepository(Expediente::class)->find($expedienteId);
  235.             }
  236.             if ($expediente === null) return [];
  237.             $tramites $expediente->getTramites() ?? [];
  238.             // Reindex sin deleted_at
  239.             $alive = [];
  240.             foreach ($tramites as $t) {
  241.                 if (method_exists($t'getDeletedAt') && $t->getDeletedAt() !== null) continue;
  242.                 $alive[] = $t;
  243.             }
  244.             if (!isset($alive[$tIdx])) return [];
  245.             $tramite $alive[$tIdx];
  246.             $schemaJson method_exists($tramite'getSchema') ? $tramite->getSchema() : null;
  247.             if (!is_string($schemaJson) || $schemaJson === '') return [];
  248.             $schema json_decode($schemaJsontrue);
  249.             if (!is_array($schema)) return [];
  250.             $properties $schema['properties'] ?? [];
  251.             // Datos POST de este tramite
  252.             $tramitePost = [];
  253.             for ($i 0$i count($path); $i++) {
  254.                 if ($path[$i] === 'tramites' && isset($path[$i 1])) {
  255.                     $uniqid $path[0] ?? null;
  256.                     if ($uniqid !== null && isset($post[$uniqid]['tramites'][$tIdx]['form'])) {
  257.                         $tramitePost $post[$uniqid]['tramites'][$tIdx]['form'];
  258.                     }
  259.                     break;
  260.                 }
  261.             }
  262.             $missing = [];
  263.             $this->collectRequiredEmpty($properties$tramitePost''$missing);
  264.             return $missing;
  265.         } catch (Throwable $e) {
  266.             return [];
  267.         }
  268.     }
  269.     /**
  270.      * Recorre recursivamente properties del schema buscando required-vacíos
  271.      * en $data (rama del POST). Acumula labels humanos en $out.
  272.      */
  273.     private function collectRequiredEmpty(array $properties$datastring $crumb, array &$out): void
  274.     {
  275.         if (!is_array($data)) $data = [];
  276.         foreach ($properties as $name => $prop) {
  277.             if (!is_array($prop)) continue;
  278.             $type $prop['type'] ?? '';
  279.             $title $prop['title'] ?? $name;
  280.             $title is_string($title) ? $title : (string) $name;
  281.             $titleHuman $this->humanizeLabel($title);
  282.             $required $prop['options']['required'] ?? false;
  283.             $branchCrumb $titleHuman// solo el nombre del campo, no breadcrumb
  284.             $value $data[$name] ?? null;
  285.             $isEmpty $value === null || $value === '' || $value === [] || $value === false;
  286.             if ($required && $isEmpty && $type !== 'object' && $type !== 'submit') {
  287.                 $out[] = $branchCrumb;
  288.             }
  289.             if ($type === 'object' && isset($prop['properties']) && is_array($prop['properties'])) {
  290.                 $this->collectRequiredEmpty($prop['properties'], $value$branchCrumb$out);
  291.             }
  292.         }
  293.     }
  294.     /**
  295.      * Convierte un label tipo "label.responsable_encargado" o
  296.      * "label.seleccion_subfase" a texto legible cuando no hay traductor
  297.      * disponible: quita el prefix "label.", reemplaza "_" por espacio y
  298.      * capitaliza palabras.
  299.      */
  300.     private function humanizeLabel(string $label): string
  301.     {
  302.         $s preg_replace('/^label\./'''$label);
  303.         $s str_replace('_'' ', (string) $s);
  304.         return ucwords(trim($s));
  305.     }
  306.     /**
  307.      * Emite flash de error explicativo. Prioridad de información:
  308.      *  (a) errores REALES del root form (vía FormInvalidBuffer del
  309.      *      FormInvalidDiagnosticsExtension) — verdad absoluta.
  310.      *  (b) si no hay buffer, lista de required-vacíos del schema del
  311.      *      tramite — heurística.
  312.      *  (c) mensaje genérico si nada se identificó.
  313.      *
  314.      * El (a) es preferible porque cubre errores en CUALQUIER parte del
  315.      * root form (cabecera, otros tramites, etc.), no solo el tramite
  316.      * cuyo botón se pulsó. Caso real: cabecera.Intervinientes[N].Interviniente
  317.      * vacío bloquea el submit y el flash con (b) solo decía "Responsable
  318.      * Encargado" (false positive del schema del tramite).
  319.      */
  320.     private function emitExplanatoryFlash(?array $clicked, array $missing): void
  321.     {
  322.         try {
  323.             $session $this->requestStack?->getSession();
  324.             if ($session === null || !method_exists($session'getFlashBag')) return;
  325.             // FIX-260625-002: $clicked puede ser null si el heuristico
  326.             // findClickedSubmitButton no detecto el boton (su parent no contiene
  327.             // "boton" en el nombre, p.ej. A2 iniciosolicitudarbitraje). En ese
  328.             // caso usamos el label generico "continuar" como nombre del boton.
  329.             $btn $clicked['button_name'] ?? 'continuar';
  330.             // (a) errores reales del root form (preferido)
  331.             $bufferErrors $this->formInvalidBuffer?->getErrors() ?? [];
  332.             if (!empty($bufferErrors)) {
  333.                 // FIX-260625-002 — dedup por (path,msg) para no repetir el mismo
  334.                 // breadcrumb dos veces cuando varios errores del root form
  335.                 // bubble al mismo nodo.
  336.                 $seen = [];
  337.                 $paths = [];
  338.                 foreach ($bufferErrors as $e) {
  339.                     $key = ($e['path'] ?? '') . '|' . ($e['msg'] ?? '');
  340.                     if (isset($seen[$key])) continue;
  341.                     $seen[$key] = true;
  342.                     $paths[] = $this->humanizePath($e['path'] ?? '') . ' (' . ($e['msg'] ?? '') . ')';
  343.                     if (count($paths) >= 5) break;
  344.                 }
  345.                 $msg sprintf(
  346.                     'No se ha podido procesar "%s": faltan o son inválidos los campos siguientes — %s. Rellénalos y vuelve a pulsar.',
  347.                     $btn,
  348.                     implode(' · '$paths)
  349.                 );
  350.                 $session->getFlashBag()->add('sonata_flash_error'$msg);
  351.                 return;
  352.             }
  353.             // (b) heurística: required-vacíos del schema del tramite
  354.             if (!empty($missing)) {
  355.                 $list implode(', 'array_slice($missing05));
  356.                 $msg sprintf(
  357.                     'No se ha podido procesar "%s": faltan campos obligatorios (%s). Rellénalos y vuelve a pulsar.',
  358.                     $btn,
  359.                     $list
  360.                 );
  361.                 $session->getFlashBag()->add('sonata_flash_error'$msg);
  362.                 return;
  363.             }
  364.             // (c) genérico
  365.             $msg sprintf(
  366.                 'No se ha podido procesar "%s". Comprueba que has rellenado todos los campos obligatorios (marcados con *) y vuelve a pulsar.',
  367.                 $btn
  368.             );
  369.             $session->getFlashBag()->add('sonata_flash_error'$msg);
  370.         } catch (Throwable $e) {
  371.             // swallow
  372.         }
  373.     }
  374.     /**
  375.      * Convierte un path del FormInvalidBuffer (separado por puntos, con
  376.      * índices numéricos para collections) a un breadcrumb más humano.
  377.      * Ej: "cabecera__form.Intervinientes.2.Interviniente"
  378.      *   → "Cabecera › Intervinientes › #3 › Interviniente"
  379.      *
  380.      * FIX-260625-003 — Cuando el path es SOLO el uniqid Sonata (caso en que
  381.      * FormInvalidDiagnosticsExtension::pathFromCause no pudo resolver via
  382.      * cause), seguimos devolviendo "algún campo obligatorio del formulario"
  383.      * como mensaje útil (no perfecto, pero mejor que el uniqid).
  384.      *
  385.      * Cuando el path es uniqid + subpath (poco frecuente tras FIX-260625-003,
  386.      * pero posible si la normalización deja el uniqid como primer segmento),
  387.      * descartamos el primer segmento uniqid y humanizamos el resto.
  388.      *
  389.      * Cuando el path es CamelCase o snake_case multi-palabra, separamos en
  390.      * palabras legibles (TipoSujeto → "Tipo Sujeto").
  391.      */
  392.     private function humanizePath(string $path): string
  393.     {
  394.         if ($path === '') return '?';
  395.         $parts explode('.'$path);
  396.         // FIX-260625-003 — caso "uniqid solo" (legacy): si tras filtrar no
  397.         // queda nada útil, devolver fallback genérico.
  398.         $onlyUniqid = (count($parts) === 1)
  399.             && (bool) preg_match('/^s[0-9a-f]{12,}$/i'$parts[0]);
  400.         if ($onlyUniqid) {
  401.             return 'algún campo obligatorio del formulario';
  402.         }
  403.         $out = [];
  404.         foreach ($parts as $idx => $p) {
  405.             if (ctype_digit($p)) {
  406.                 $out[] = '#' . ((int) $p 1);
  407.                 continue;
  408.             }
  409.             // Uniqid Sonata como primer segmento (legacy): omitir.
  410.             if ($idx === && preg_match('/^s[0-9a-f]{12,}$/i'$p)) {
  411.                 continue;
  412.             }
  413.             $clean preg_replace('/^cabecera__form$/''Cabecera'$p);
  414.             // FIX-260625-003 — separar CamelCase (TipoSujeto → "Tipo Sujeto")
  415.             // para paths que vienen del PropertyPath del Validator.
  416.             $clean preg_replace('/_+/'' ', (string) $clean);
  417.             $clean preg_replace('/(?<=[a-z0-9])(?=[A-Z])/'' ', (string) $clean);
  418.             $out[] = ucwords(trim((string) $clean));
  419.         }
  420.         if (empty($out)) {
  421.             return 'algún campo obligatorio del formulario';
  422.         }
  423.         return implode(' › '$out);
  424.     }
  425.     /**
  426.      * Walk POST array up to maxDepth, return only the key tree (no values).
  427.      * Helps see which form sections were submitted without leaking data.
  428.      */
  429.     private function keyTree(array $arrint $maxDepthint $depth 0): array
  430.     {
  431.         $out = [];
  432.         foreach ($arr as $k => $v) {
  433.             if (is_array($v) && $depth $maxDepth) {
  434.                 $sub $this->keyTree($v$maxDepth$depth 1);
  435.                 $out[(string) $k] = empty($sub) ? '<empty>' $sub;
  436.             } else {
  437.                 $out[(string) $k] = is_array($v) ? '<truncated_array>' '<scalar>';
  438.             }
  439.         }
  440.         return $out;
  441.     }
  442.     /**
  443.      * Look for inline form error markers in the rendered HTML response.
  444.      * Symfony/Twig default form theme: .sonata-ba-field-error, .has-error,
  445.      * <ul class="list-unstyled"><li>error message</li>. Also Sonata flash
  446.      * sections (#sonata-flash-error).
  447.      *
  448.      * @return array{count_has_error:int, count_sonata_ba_error:int, samples:array<int,string>}
  449.      */
  450.     private function scanInlineFormErrors(string $body): array
  451.     {
  452.         $countHasError       substr_count($body'has-error');
  453.         $countSonataBaError  substr_count($body'sonata-ba-field-error');
  454.         $countFieldError     substr_count($body'form-error-message');
  455.         $countInvalidFeedb   substr_count($body'invalid-feedback');
  456.         $samples = [];
  457.         // Capture short snippets around the first 3 .sonata-ba-field-error or .has-error blocks.
  458.         if (preg_match_all(
  459.             '/(?:sonata-ba-field-error|has-error|form-error-message|invalid-feedback)[^<]{0,400}<[^>]*>([^<\n]{1,200})/i',
  460.             $body,
  461.             $m
  462.         )) {
  463.             foreach (array_slice($m[1], 05) as $snippet) {
  464.                 $clean trim(preg_replace('/\s+/'' 'strip_tags($snippet)));
  465.                 if ($clean !== '' && strlen($clean) > 3) {
  466.                     $samples[] = mb_substr($clean0180);
  467.                 }
  468.             }
  469.         }
  470.         // Also: explicit Sonata flash error in HTML even if FlashBag was already peeked elsewhere.
  471.         if (preg_match('/sonata-flash-error[^>]*>([^<]{1,300})/i'$body$m2)) {
  472.             $samples[] = 'FLASH: ' trim(preg_replace('/\s+/'' '$m2[1]));
  473.         }
  474.         return [
  475.             'has_error'           => $countHasError,
  476.             'sonata_ba_error'     => $countSonataBaError,
  477.             'form_error_message'  => $countFieldError,
  478.             'invalid_feedback'    => $countInvalidFeedb,
  479.             'samples'             => $samples,
  480.         ];
  481.     }
  482.     /**
  483.      * @return array<int|string, mixed>
  484.      */
  485.     private function extractFlashErrors(): array
  486.     {
  487.         try {
  488.             $session $this->requestStack?->getSession();
  489.             if ($session === null || !method_exists($session'getFlashBag')) {
  490.                 return [];
  491.             }
  492.             $bag $session->getFlashBag();
  493.             $peeked = [];
  494.             foreach (['error''sonata_flash_error''danger''warning'] as $type) {
  495.                 $msgs $bag->peek($type);
  496.                 if (!empty($msgs)) {
  497.                     $peeked[$type] = $msgs;
  498.                 }
  499.             }
  500.             return $peeked;
  501.         } catch (Throwable $e) {
  502.             return [];
  503.         }
  504.     }
  505.     private function resolveUser(): ?string
  506.     {
  507.         try {
  508.             $token $this->tokenStorage?->getToken();
  509.             if ($token === null) {
  510.                 return null;
  511.             }
  512.             return method_exists($token'getUserIdentifier')
  513.                 ? $token->getUserIdentifier()
  514.                 : (string) $token->getUser();
  515.         } catch (Throwable $e) {
  516.             return null;
  517.         }
  518.     }
  519. }