diff --git a/src/ClientDataToEntityConverter.php b/src/ClientDataToEntityConverter.php
index a8c697ea780af9f001b7db2833a0abec275538b6..434dcf4684fcc21d2e68be369c667eaf947c364f 100644
--- a/src/ClientDataToEntityConverter.php
+++ b/src/ClientDataToEntityConverter.php
@@ -4,17 +4,16 @@ namespace Drupal\experience_builder;
 
 use Drupal\Core\Access\AccessException;
 use Drupal\Core\Entity\EntityConstraintViolationList;
-use Drupal\Core\Entity\EntityConstraintViolationListInterface;
 use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Entity\FieldableEntityInterface;
 use Drupal\Core\Field\FieldItemListInterface;
 use Drupal\Core\Form\FormState;
 use Drupal\experience_builder\Controller\ClientServerConversionTrait;
+use Drupal\experience_builder\Exception\ConstraintViolationException;
 use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
 use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem;
 use Symfony\Component\Validator\ConstraintViolation;
-use Symfony\Component\Validator\ConstraintViolationInterface;
 
 class ClientDataToEntityConverter {
 
@@ -25,43 +24,37 @@ class ClientDataToEntityConverter {
     private readonly EntityDisplayRepositoryInterface $entityDisplayRepository,
   ) {}
 
-  public function convert(array $client_data, FieldableEntityInterface $entity): EntityConstraintViolationListInterface {
+  public function convert(array $client_data, FieldableEntityInterface $entity): void {
     // @todo Security hardening: any key besides `layout`, `model` and `entity_form_fields` should trigger an error response.
     ['layout' => $layout, 'model' => $model, 'entity_form_fields' => $entity_form_fields] = $client_data;
 
-    [$tree, $props, $violations] = $this->convertClientToServer($layout, $model);
-    if ($violations->count() > 0) {
-      return new EntityConstraintViolationList($entity, iterator_to_array($violations));
-    }
-
     $field_name = InternalXbFieldNameResolver::getXbFieldName($entity);
     $item = $entity->get($field_name)->first();
     assert($item instanceof ComponentTreeItem);
-    $item->setValue([
-      'tree' => json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_FORCE_OBJECT),
-      'props' => json_encode($props, JSON_UNESCAPED_UNICODE),
-    ]);
-    $violations = $this->setEntityFields($entity, $entity_form_fields);
-    if ($violations->count() > 0) {
-      return $violations;
+
+    try {
+      $item->setValue($this->convertClientToServer($layout, $model));
+    }
+    catch (ConstraintViolationException $e) {
+      // @todo Remove iterator_to_array() after https://www.drupal.org/project/drupal/issues/3497677
+      throw new ConstraintViolationException(new EntityConstraintViolationList($entity, iterator_to_array($e->getConstraintViolationList())));
     }
+
+    $this->setEntityFields($entity, $entity_form_fields);
     $original_entity_violations = $entity->validate();
     // Validation happens using the server-side representation, but the
     // error message should use the client-side representation received in
     // the request body.
     // @see ::clientLayoutToServerTree()
     // @see ::clientModelToServerProps()
-    $transformed_violations = new EntityConstraintViolationList($entity, array_map(
-      fn (ConstraintViolationInterface $v) => match (TRUE) {
-        str_starts_with($v->getPropertyPath(), "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . "]") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . ']', 'layout.children'),
-        // @todo Perform a more complex transformation to accurately point to non-root-level components, OR remove the need for that in https://www.drupal.org/project/experience_builder/issues/3467954
-        str_starts_with($v->getPropertyPath(), "$field_name.0.tree") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.tree", 'layout'),
-        str_starts_with($v->getPropertyPath(), "$field_name.0.props") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.props", 'model'),
-        default => $v,
-      },
-      iterator_to_array($original_entity_violations),
-    ));
-    return $transformed_violations;
+    if ($original_entity_violations->count()) {
+      // @todo Remove iterator_to_array() after https://www.drupal.org/project/drupal/issues/3497677
+      throw (new ConstraintViolationException(new EntityConstraintViolationList($entity, iterator_to_array($original_entity_violations))))->renamePropertyPaths([
+        "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . "]" => 'layout.children',
+        "$field_name.0.tree" => 'layout',
+        "$field_name.0.props" => 'model',
+      ]);
+    }
   }
 
   /**
@@ -108,7 +101,7 @@ class ClientDataToEntityConverter {
     throw new AccessException("The current user is not allowed to update the field '$field_name'.");
   }
 
-  private function setEntityFields(FieldableEntityInterface $entity, array $entity_form_fields): EntityConstraintViolationListInterface {
+  private function setEntityFields(FieldableEntityInterface $entity, array $entity_form_fields): void {
     // Create a form state from the received entity fields.
     $form_state = new FormState();
     $form_state->set('entity', $entity);
@@ -152,7 +145,9 @@ class ClientDataToEntityConverter {
         $violations_list->add(new ConstraintViolation($e->getMessage(), $e->getMessage(), [], $field_value, "entity_form_fields.$field_name", $field_value));
       }
     }
-    return $violations_list;
+    if ($violations_list->count()) {
+      throw new ConstraintViolationException($violations_list);
+    }
   }
 
 }
diff --git a/src/ComponentSource/ComponentSourceInterface.php b/src/ComponentSource/ComponentSourceInterface.php
index b7a786a11e51a69e2132d32c1b2c876ed67de4fb..2ad45d7fba0ae1d3f9412b3403f9990720a10a87 100644
--- a/src/ComponentSource/ComponentSourceInterface.php
+++ b/src/ComponentSource/ComponentSourceInterface.php
@@ -152,10 +152,11 @@ interface ComponentSourceInterface extends PluginInspectionInterface, Derivative
   public function buildConfigurationForm(array $form, FormStateInterface $form_state, string $component_instance_uuid = '', ?EntityInterface $entity = NULL, array $settings = []): array;
 
   /**
-   * @return array{0: array<string, \Drupal\experience_builder\PropSource\StaticPropSource>, 1: \Symfony\Component\Validator\ConstraintViolationListInterface}
+   * @return array<string, \Drupal\experience_builder\PropSource\StaticPropSource>
+   * @throws \Drupal\experience_builder\Exception\ConstraintViolationException
    *
    * @todo Refactor to use the Symfony denormalizer infrastructure?
    */
-  public function createPropsForComponent(string $component_instance_uuid, Component $component, array $client_props);
+  public function createPropsForComponent(string $component_instance_uuid, Component $component, array $client_props): array;
 
 }
diff --git a/src/Controller/ApiConfigControllers.php b/src/Controller/ApiConfigControllers.php
index 53e5b25442bbfe7438f13ffdef04f75a09c5e634..0f533dbb9d1aef2afe5dd6feea6c6e3845262801 100644
--- a/src/Controller/ApiConfigControllers.php
+++ b/src/Controller/ApiConfigControllers.php
@@ -13,6 +13,7 @@ use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\GeneratedUrl;
 use Drupal\Core\Url;
 use Drupal\experience_builder\Entity\XbHttpApiEligibleConfigEntityInterface;
+use Drupal\experience_builder\Exception\ConstraintViolationException;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -103,9 +104,7 @@ final class ApiConfigControllers extends ApiControllerBase {
       ->getStorage($xb_config_entity_type_id)
       ->create($denormalized);
     assert($xb_config_entity instanceof XbHttpApiEligibleConfigEntityInterface);
-    if ($validation_errors_response = self::createJsonResponseFromViolations($xb_config_entity->getTypedData()->validate())) {
-      return $validation_errors_response;
-    }
+    $this->validate($xb_config_entity);
 
     // Save the XB config entity, respond with a 201.
     $xb_config_entity->save();
@@ -139,9 +138,7 @@ final class ApiConfigControllers extends ApiControllerBase {
     foreach ($denormalized as $property_name => $property_value) {
       $xb_config_entity->set($property_name, $property_value);
     }
-    if ($validation_errors_response = self::createJsonResponseFromViolations($xb_config_entity->getTypedData()->validate())) {
-      return $validation_errors_response;
-    }
+    $this->validate($xb_config_entity);
 
     // Save the XB config entity, respond with a 200.
     $xb_config_entity->save();
@@ -151,6 +148,13 @@ final class ApiConfigControllers extends ApiControllerBase {
     return new JsonResponse(status: 200, data: $normalization);
   }
 
+  private function validate(XbHttpApiEligibleConfigEntityInterface $xb_config_entity): void {
+    $violations = $xb_config_entity->getTypedData()->validate();
+    if ($violations->count()) {
+      throw new ConstraintViolationException($violations);
+    }
+  }
+
   /**
    * Normalizes all config entities of a given config entity type.
    *
diff --git a/src/Controller/ApiContentUpdateForDemoController.php b/src/Controller/ApiContentUpdateForDemoController.php
index d502cf537672c1b48debe72925a013a99250658f..25abf4f5100df44b66b35b0502882514858742ca 100644
--- a/src/Controller/ApiContentUpdateForDemoController.php
+++ b/src/Controller/ApiContentUpdateForDemoController.php
@@ -33,14 +33,11 @@ final class ApiContentUpdateForDemoController extends ApiControllerBase {
   public function __invoke(FieldableEntityInterface $entity): JsonResponse {
     $auto_save = $this->autoSaveManager->getAutoSaveData($entity);
     assert(is_array($auto_save));
-    $violations = $this->clientDataToEntityConverter->convert([
+    $this->clientDataToEntityConverter->convert([
       'layout' => reset($auto_save['layout']),
       'model' => $auto_save['model'],
       'entity_form_fields' => $auto_save['entity_form_fields'],
     ], $entity);
-    if ($validation_errors_response = self::createJsonResponseFromViolations($violations)) {
-      return $validation_errors_response;
-    }
 
     return self::save($entity);
   }
diff --git a/src/Controller/ApiControllerBase.php b/src/Controller/ApiControllerBase.php
index 1cc14eaf5f9952fc2423a112cfa0c9353742f435..fe0ab54b05d97c6e4ee2afdf1d67e7c3e5c2974a 100644
--- a/src/Controller/ApiControllerBase.php
+++ b/src/Controller/ApiControllerBase.php
@@ -3,7 +3,7 @@
 namespace Drupal\experience_builder\Controller;
 
 use Drupal\Core\Entity\EntityConstraintViolationListInterface;
-use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\experience_builder\EventSubscriber\ApiExceptionSubscriber;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\Validator\ConstraintViolationInterface;
 use Symfony\Component\Validator\ConstraintViolationListInterface;
@@ -14,32 +14,6 @@ use Symfony\Component\Validator\ConstraintViolationListInterface;
  */
 class ApiControllerBase {
 
-  /**
-   * Creates a JSON:API-style error response from a list of violations.
-   *
-   * @param \Symfony\Component\Validator\ConstraintViolationListInterface $violations
-   *   The violations list.
-   *
-   * @return \Symfony\Component\HttpFoundation\JsonResponse|null
-   *   A JSON:API-style error response, with a top-level `errors` member that
-   *   contains an array of `error` objects.
-   *
-   * @see https://jsonapi.org/format/#document-top-level
-   * @see https://jsonapi.org/format/#error-objects
-   */
-  protected static function createJsonResponseFromViolations(ConstraintViolationListInterface $violations): ?JsonResponse {
-    if ($violations->count() === 0) {
-      return NULL;
-    }
-
-    return new JsonResponse(status: 422, data: [
-      'errors' => array_map(
-        fn($violation) => self::violationToJsonApiStyleErrorObject($violation),
-        iterator_to_array($violations)
-      ),
-    ]);
-  }
-
   /**
    * Creates a JSON:API-style error response from a set of entity violations.
    *
@@ -62,7 +36,7 @@ class ApiControllerBase {
     return new JsonResponse(status: 422, data: [
       'errors' => \array_reduce($violationSets, static fn(array $carry, ConstraintViolationListInterface $violationList): array => [
         ...$carry,
-        ...\array_map(static fn(ConstraintViolationInterface $violation) => self::violationToJsonApiStyleErrorObject(
+        ...\array_map(static fn(ConstraintViolationInterface $violation) => ApiExceptionSubscriber::violationToJsonApiStyleErrorObject(
           $violation,
           $violationList instanceof EntityConstraintViolationListInterface ? $violationList->getEntity() : NULL,
         ), \iterator_to_array($violationList)),
@@ -70,41 +44,4 @@ class ApiControllerBase {
     ]);
   }
 
-  /**
-   * Transforms a constraint violation to a JSON:API-style error object.
-   *
-   * @param \Symfony\Component\Validator\ConstraintViolationInterface $violation
-   *   A validation constraint violation.
-   * @param \Drupal\Core\Entity\FieldableEntityInterface|null $entity
-   *   An associated entity if appropriate.
-   *
-   * @return array{'detail': string, 'source': array{'pointer': string}}
-   *   A subset of a JSON:API error object.
-   *
-   * @see https://jsonapi.org/format/#error-objects
-   * @see \Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer
-   */
-  private static function violationToJsonApiStyleErrorObject(
-    ConstraintViolationInterface $violation,
-    ?FieldableEntityInterface $entity = NULL,
-  ): array {
-    $meta = [];
-    if ($entity !== NULL) {
-      $meta = [
-        'meta' => [
-          'entity_type' => $entity->getEntityTypeId(),
-          'entity_id' => $entity->id(),
-          'label' => $entity->label(),
-        ],
-      ];
-    }
-    return [
-      'detail' => (string) $violation->getMessage(),
-      'source' => [
-        // @todo Correctly convert to a JSON pointer.
-        'pointer' => $violation->getPropertyPath(),
-      ],
-    ] + $meta;
-  }
-
 }
diff --git a/src/Controller/ApiPreviewController.php b/src/Controller/ApiPreviewController.php
index c4948cddea9f197def3cf783fad565539c4dfd7c..475f5e196a49c07ec84cc2502a3364bfa5b21ec8 100644
--- a/src/Controller/ApiPreviewController.php
+++ b/src/Controller/ApiPreviewController.php
@@ -9,6 +9,7 @@ use Drupal\Core\Render\Element;
 use Drupal\Core\TypedData\TypedDataManagerInterface;
 use Drupal\experience_builder\AutoSave\AutoSaveManager;
 use Drupal\experience_builder\Entity\PageTemplate;
+use Drupal\experience_builder\Exception\ConstraintViolationException;
 use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
 use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem;
 use Drupal\experience_builder\Render\PreviewEnvelope;
@@ -124,10 +125,13 @@ final class ApiPreviewController {
       'data_definition' => $field_item_definition,
     ]);
 
-    // @todo Use $violations in https://www.drupal.org/project/experience_builder/issues/3485878
-    // phpcs:disable DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable
-    [$tree, $violations] = self::clientLayoutToServerTree($layout);
-    // phpcs:enable
+    try {
+      $tree = self::clientLayoutToServerTree($layout);
+    }
+    catch (ConstraintViolationException) {
+      // @todo Handle violations in https://www.drupal.org/project/experience_builder/issues/3485878
+      $tree = NULL;
+    }
 
     // This uses a partial override of the XB field type, because the client is
     // sending explicit prop values in its `model`, not prop sources. Use these
diff --git a/src/Controller/ApiPublishAllController.php b/src/Controller/ApiPublishAllController.php
index 83ef244c7f8438ae77d0fdddb15901efa0fcae99..9040d60cc23e72f0ea60704f9988ca34b8042a29 100644
--- a/src/Controller/ApiPublishAllController.php
+++ b/src/Controller/ApiPublishAllController.php
@@ -93,41 +93,34 @@ class ApiPublishAllController extends ApiControllerBase {
     $violationSets = [];
     $entities = [];
     foreach ($all_auto_saves as $auto_save) {
-      $entity = $this->entityTypeManager->getStorage($auto_save['entity_type'])->load($auto_save['entity_id']);
-      if ($entity instanceof PageTemplate) {
-        try {
+      $entity = $this->entityTypeManager->getStorage($auto_save['entity_type'])
+        ->load($auto_save['entity_id']);
+
+      try {
+        if ($entity instanceof PageTemplate) {
           $entity = $entity->forAutoSaveData($auto_save['data']);
+          $entity->enforceIsNew(FALSE);
+          $this->validatePageTemplate($entity);
         }
-        catch (ConstraintViolationException $e) {
-          $violationSets[] = $e->getConstraintViolationList();
-          continue;
+        else {
+          assert($entity instanceof FieldableEntityInterface);
+          // Pluck out only the content region.
+          $content_region = \array_values(\array_filter($auto_save['data']['layout'], static fn(array $region) => $region['id'] === 'content'));
+          $this->clientDataToEntityConverter->convert([
+            'layout' => reset($content_region),
+            'model' => $auto_save['data']['model'],
+            'entity_form_fields' => $auto_save['data']['entity_form_fields'],
+          ], $entity);
         }
-        $entity->enforceIsNew(FALSE);
-        // @todo Use a violation list that allows keeping track of the entity
-        // context.
-        // @see https://www.drupal.org/project/drupal/issues/3495599
-        $violations = $this->typedConfigManager->createFromNameAndData($entity->getConfigDependencyName(), $entity->toArray())->validate();
-      }
-      else {
-        assert($entity instanceof FieldableEntityInterface);
-        // Pluck out only the content region.
-        $content_region = \array_values(\array_filter($auto_save['data']['layout'], static fn(array $region) => $region['id'] === 'content'));
-        $violations = $this->clientDataToEntityConverter->convert([
-          'layout' => reset($content_region),
-          'model' => $auto_save['data']['model'],
-          'entity_form_fields' => $auto_save['data']['entity_form_fields'],
-        ], $entity);
+
+        $entities[] = $entity;
       }
-      $entities[] = $entity;
-      if ($violations->count() > 0) {
-        $violationSets[] = $violations;
+      catch (ConstraintViolationException $e) {
+        $violationSets[] = $e->getConstraintViolationList();
       }
     }
-    if (\count($violationSets) > 0) {
-      $validation_errors_response = self::createJsonResponseFromViolationSets(...$violationSets);
-      if ($validation_errors_response !== NULL) {
-        return $validation_errors_response;
-      }
+    if ($validation_errors_response = self::createJsonResponseFromViolationSets(...$violationSets)) {
+      return $validation_errors_response;
     }
     foreach ($entities as $entity) {
       $entity->save();
@@ -136,4 +129,14 @@ class ApiPublishAllController extends ApiControllerBase {
     return new JsonResponse(data: ['message' => new PluralTranslatableMarkup(\count($all_auto_saves), 'Successfully published 1 item.', 'Successfully published @count items.')], status: 200);
   }
 
+  private function validatePageTemplate(PageTemplate $entity): void {
+    // @todo Use a violation list that allows keeping track of the entity
+    // context.
+    // @see https://www.drupal.org/project/drupal/issues/3495599
+    $violations = $this->typedConfigManager->createFromNameAndData($entity->getConfigDependencyName(), $entity->toArray())->validate();
+    if ($violations->count() > 0) {
+      throw new ConstraintViolationException($violations);
+    }
+  }
+
 }
diff --git a/src/Controller/ClientServerConversionTrait.php b/src/Controller/ClientServerConversionTrait.php
index f7bd9548b453dfa48144ca9215f718bb512e73c8..787d9a6da522f142bea85141034e348b181a981c 100644
--- a/src/Controller/ClientServerConversionTrait.php
+++ b/src/Controller/ClientServerConversionTrait.php
@@ -6,9 +6,8 @@ namespace Drupal\experience_builder\Controller;
 
 use Drupal\Core\TypedData\DataDefinition;
 use Drupal\experience_builder\Entity\Component;
+use Drupal\experience_builder\Exception\ConstraintViolationException;
 use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
-use Symfony\Component\Validator\ConstraintViolation;
-use Symfony\Component\Validator\ConstraintViolationInterface;
 use Symfony\Component\Validator\ConstraintViolationList;
 
 /**
@@ -20,9 +19,10 @@ trait ClientServerConversionTrait {
   /**
    * @todo Refactor/remove in https://www.drupal.org/project/experience_builder/issues/3467954.
    *
-   * @return array{0: ComponentTreeStructureArray, 1: \Symfony\Component\Validator\ConstraintViolationListInterface}
+   * @phpstan-return ComponentTreeStructureArray
+   * @throws \Drupal\experience_builder\Exception\ConstraintViolationException
    */
-  private static function clientLayoutToServerTree(array $layout) : array {
+  private static function clientLayoutToServerTree(array $layout): array {
     // Transform client-side representation to server-side representation.
     // The entire component tree is nested under the reserved root UUID.
     $tree = self::doClientSlotToServerTree($layout, [], ComponentTreeStructure::ROOT_UUID);
@@ -32,8 +32,11 @@ trait ClientServerConversionTrait {
     $component_tree_structure = new ComponentTreeStructure($definition, 'component_tree_structure');
     $component_tree_structure->setValue(json_encode($tree, JSON_UNESCAPED_UNICODE));
     $violations = $component_tree_structure->validate();
+    if ($violations->count()) {
+      throw new ConstraintViolationException($violations);
+    }
 
-    return [$tree, $violations];
+    return $tree;
   }
 
   /**
@@ -85,7 +88,8 @@ trait ClientServerConversionTrait {
   }
 
   /**
-   * @return array{0: array<string, array<string, \Drupal\experience_builder\PropSource\StaticPropSource>>, 1: \Symfony\Component\Validator\ConstraintViolationList}
+   * @return array<string, array<string, \Drupal\experience_builder\PropSource\StaticPropSource>>
+   * @throws \Drupal\experience_builder\Exception\ConstraintViolationException
    */
   private function clientModelToServerProps(array $tree, array $model): array {
     $definition = DataDefinition::create('component_tree_structure');
@@ -99,20 +103,28 @@ trait ClientServerConversionTrait {
     foreach ($model as $uuid => $client_props) {
       $component = Component::load($component_tree_structure->getComponentId($uuid));
       assert($component instanceof Component);
-      [$props[$uuid], $violations_for_component_instance] = $component->getComponentSource()->createPropsForComponent($uuid, $component, $client_props);
-      foreach ($violations_for_component_instance as $violation) {
-        // We use ::add here rather than ::addAll because ::addAll doesn't reset
-        // the internal groupings in EntityConstraintViolationList whereas ::add
-        // does.
-        // @see https://drupal.org/i/3490588
-        $violation_list->add($violation);
+      try {
+        $props[$uuid] = $component->getComponentSource()->createPropsForComponent($uuid, $component, $client_props);
+      }
+      catch (ConstraintViolationException $e) {
+        foreach ($e->getConstraintViolationList() as $violation) {
+          // We use ::add here rather than ::addAll because ::addAll doesn't reset
+          // the internal groupings in EntityConstraintViolationList whereas ::add
+          // does.
+          // @todo Remove this comment and change this foreach loop to a single ::addAll() call after https://drupal.org/i/3490588 lands.
+          $violation_list->add($violation);
+        }
       }
     }
-    return [$props, $violation_list];
+    if ($violation_list->count()) {
+      throw new ConstraintViolationException($violation_list);
+    }
+    return $props;
   }
 
   /**
-   * @return array{0: ?array, 1: ?array, 2: \Symfony\Component\Validator\ConstraintViolationList}
+   * @return array{tree: string, props: string}
+   * @throws \Drupal\experience_builder\Exception\ConstraintViolationException
    */
   protected function convertClientToServer(array $layout, array $model): array {
     // Denormalize the `layout` the client sent into a value that the server-
@@ -120,13 +132,11 @@ trait ClientServerConversionTrait {
     // (This is the value for the `tree` field prop on the XB field type.)
     // @see \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure
     // @see \Drupal\experience_builder\Plugin\Validation\Constraint\ComponentTreeStructureConstraintValidator
-    [$tree, $violations] = self::clientLayoutToServerTree($layout);
-    $transformed_violations = new ConstraintViolationList(array_map(
-      fn (ConstraintViolationInterface $v) => self::violationWithPropertyPathReplacePrefix($v, '[' . ComponentTreeStructure::ROOT_UUID . ']', "layout.children"),
-      iterator_to_array($violations),
-    ));
-    if ($transformed_violations->count() > 0) {
-      return [NULL, NULL, $transformed_violations];
+    try {
+      $tree = self::clientLayoutToServerTree($layout);
+    }
+    catch (ConstraintViolationException $e) {
+      throw $e->renamePropertyPaths(["[" . ComponentTreeStructure::ROOT_UUID . "]" => 'layout.children']);
     }
 
     // Denormalize the `model` the client sent into a value that the server-side
@@ -136,10 +146,7 @@ trait ClientServerConversionTrait {
     // ⚠️ TRICKY: in order to denormalize `model`, `layout` must already been
     // been denormalized to `tree`, because only those values in `model` that
     // are for actually existing XB components can be denormalized.
-    [$props, $violations] = $this->clientModelToServerProps($tree, $model);
-    if ($violations->count() > 0) {
-      return [NULL, NULL, $violations];
-    }
+    $props = $this->clientModelToServerProps($tree, $model);
 
     // Update the entity, validate and save.
     // Note: constructing ComponentTreeStructure from `layout` and
@@ -155,22 +162,11 @@ trait ClientServerConversionTrait {
         $props_prepared_for_saving[$component_instance_uuid][$prop_name] = json_decode((string) $prop_source, TRUE);
       }
     }
-    return [$tree, $props_prepared_for_saving, new ConstraintViolationList()];
-  }
 
-  private static function violationWithPropertyPathReplacePrefix(ConstraintViolationInterface $v, string $prefix_original, string $prefix_new): ConstraintViolationInterface {
-    return new ConstraintViolation(
-      $v->getMessage(),
-      $v->getMessageTemplate(),
-      $v->getParameters(),
-      $v->getRoot(),
-      preg_replace('/^' . preg_quote($prefix_original, '/') . '/', $prefix_new, $v->getPropertyPath()),
-      $v->getInvalidValue(),
-      $v->getPlural(),
-      $v->getCode(),
-      $v->getConstraint(),
-      $v->getCause(),
-    );
+    return [
+      'tree' => json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_FORCE_OBJECT | JSON_THROW_ON_ERROR),
+      'props' => json_encode($props_prepared_for_saving, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
+    ];
   }
 
 }
diff --git a/src/Entity/PageTemplate.php b/src/Entity/PageTemplate.php
index 698cf8795598629239a482d08b0b6712e2c3818a..d2a0f195c51f7df79db866912838dc0a5c538023 100644
--- a/src/Entity/PageTemplate.php
+++ b/src/Entity/PageTemplate.php
@@ -86,9 +86,11 @@ final class PageTemplate extends ConfigEntityBase implements XbHttpApiEligibleCo
     $treeItems = \array_intersect_key($values['component_trees'] ?? [], \array_flip(['content']));
     $allViolations = new ConstraintViolationList();
     foreach ($autoSaveData['layout'] as $region) {
-      [$tree, $violations] = self::clientLayoutToServerTree($region);
-      $allViolations->addAll($violations);
-      if ($tree === NULL) {
+      try {
+        $tree = self::clientLayoutToServerTree($region);
+      }
+      catch (ConstraintViolationException $e) {
+        $allViolations->addAll($e->getConstraintViolationList());
         continue;
       }
 
@@ -104,9 +106,11 @@ final class PageTemplate extends ConfigEntityBase implements XbHttpApiEligibleCo
       $component_tree_structure = new ComponentTreeStructure($definition, 'component_tree_structure');
       $component_tree_structure->setValue(json_encode($tree, JSON_UNESCAPED_UNICODE));
 
-      [$client_props, $violations] = $this->clientModelToServerProps($tree, \array_intersect_key($autoSaveData['model'], \array_flip($component_tree_structure->getComponentInstanceUuids())));
-      $allViolations->addAll($violations);
-      if ($client_props === NULL) {
+      try {
+        $client_props = $this->clientModelToServerProps($tree, \array_intersect_key($autoSaveData['model'], \array_flip($component_tree_structure->getComponentInstanceUuids())));
+      }
+      catch (ConstraintViolationException $e) {
+        $allViolations->addAll($e->getConstraintViolationList());
         continue;
       }
 
@@ -129,7 +133,7 @@ final class PageTemplate extends ConfigEntityBase implements XbHttpApiEligibleCo
       ];
     }
     if ($allViolations->count() > 0) {
-      throw ConstraintViolationException::forViolationList($allViolations);
+      throw new ConstraintViolationException($allViolations);
     }
     $values['component_trees'] = $treeItems;
     return static::create($values);
diff --git a/src/EventSubscriber/ApiExceptionSubscriber.php b/src/EventSubscriber/ApiExceptionSubscriber.php
index c024ad31b7ee080475a10632851cfd618079f6ae..deb79ca65cd3ea7d0766fabdf2d203a40f2a5404 100644
--- a/src/EventSubscriber/ApiExceptionSubscriber.php
+++ b/src/EventSubscriber/ApiExceptionSubscriber.php
@@ -5,15 +5,18 @@ namespace Drupal\experience_builder\EventSubscriber;
 use Drupal\Core\Cache\CacheableDependencyInterface;
 use Drupal\Core\Cache\CacheableJsonResponse;
 use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
 use Drupal\Core\ParamConverter\ParamNotConvertedException;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\Core\Session\AccountInterface;
+use Drupal\experience_builder\Exception\ConstraintViolationException;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpKernel\Event\ExceptionEvent;
 use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
 use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\Validator\ConstraintViolationInterface;
 
 /**
  * Handle exceptions for Experience Builder API routes.
@@ -56,8 +59,16 @@ final class ApiExceptionSubscriber implements EventSubscriberInterface {
         default => Response::HTTP_INTERNAL_SERVER_ERROR,
       };
 
-      // Generate a JSON response with a message when the status is not 404
-      if ($status !== 404) {
+      if ($exception instanceof ConstraintViolationException) {
+        $status = Response::HTTP_UNPROCESSABLE_ENTITY;
+        $response['errors'] = array_map(
+          fn($violation) => self::violationToJsonApiStyleErrorObject($violation),
+          iterator_to_array($exception->getConstraintViolationList())
+        );
+      }
+
+      // Generate a JSON response with a message when the status is not 404 or 422.
+      if ($status !== Response::HTTP_NOT_FOUND && $status !== Response::HTTP_UNPROCESSABLE_ENTITY) {
         $response['message'] = $exception->getMessage();
       }
 
@@ -99,4 +110,41 @@ final class ApiExceptionSubscriber implements EventSubscriberInterface {
     return $events;
   }
 
+  /**
+   * Transforms a constraint violation to a JSON:API-style error object.
+   *
+   * @param \Symfony\Component\Validator\ConstraintViolationInterface $violation
+   *   A validation constraint violation.
+   * @param \Drupal\Core\Entity\FieldableEntityInterface|null $entity
+   *   An associated entity if appropriate.
+   *
+   * @return array{'detail': string, 'source': array{'pointer': string}}
+   *   A subset of a JSON:API error object.
+   *
+   * @see https://jsonapi.org/format/#error-objects
+   * @see \Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer
+   */
+  public static function violationToJsonApiStyleErrorObject(
+    ConstraintViolationInterface $violation,
+    ?FieldableEntityInterface $entity = NULL,
+  ): array {
+    $meta = [];
+    if ($entity !== NULL) {
+      $meta = [
+        'meta' => [
+          'entity_type' => $entity->getEntityTypeId(),
+          'entity_id' => $entity->id(),
+          'label' => $entity->label(),
+        ],
+      ];
+    }
+    return [
+      'detail' => (string) $violation->getMessage(),
+      'source' => [
+        // @todo Correctly convert to a JSON pointer.
+        'pointer' => $violation->getPropertyPath(),
+      ],
+    ] + $meta;
+  }
+
 }
diff --git a/src/Exception/ConstraintViolationException.php b/src/Exception/ConstraintViolationException.php
index ff0fc289f2ea89c621b1f86eaaff7abbf060df65..4e13b8b6a7826776db6c9694188d0bf44d53c47b 100644
--- a/src/Exception/ConstraintViolationException.php
+++ b/src/Exception/ConstraintViolationException.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\experience_builder\Exception;
 
+use Symfony\Component\Validator\ConstraintViolation;
 use Symfony\Component\Validator\ConstraintViolationListInterface;
 
 /**
@@ -11,15 +12,30 @@ use Symfony\Component\Validator\ConstraintViolationListInterface;
  */
 final class ConstraintViolationException extends \Exception {
 
-  private function __construct(protected ConstraintViolationListInterface $constraintViolationList, string $message) {
+  public function __construct(protected ConstraintViolationListInterface $constraintViolationList, string $message = 'Validation errors exist') {
     parent::__construct($message);
   }
 
-  public static function forViolationList(ConstraintViolationListInterface $violation_list): static {
-    return new static(
-      $violation_list,
-      'Validation errors exist',
-    );
+  public function renamePropertyPaths(array $map): self {
+    foreach ($map as $prefix_original => $prefix_new) {
+      foreach ($this->constraintViolationList as $key => $v) {
+        if (str_starts_with($v->getPropertyPath(), $prefix_original)) {
+          $this->constraintViolationList[$key] = new ConstraintViolation(
+            $v->getMessage(),
+            $v->getMessageTemplate(),
+            $v->getParameters(),
+            $v->getRoot(),
+            preg_replace('/^' . preg_quote($prefix_original, '/') . '/', $prefix_new, $v->getPropertyPath()),
+            $v->getInvalidValue(),
+            $v->getPlural(),
+            $v->getCode(),
+            $v->getConstraint(),
+            $v->getCause(),
+          );
+        }
+      }
+    }
+    return $this;
   }
 
   /**
diff --git a/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php b/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php
index 6a785506a86b4eab6e6b956fb1ad27feff904f89..ad5a67cd40cfe7ab667ea5310c322c697b9635b6 100644
--- a/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php
+++ b/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php
@@ -19,7 +19,6 @@ use Drupal\experience_builder\Entity\Component;
 use Drupal\experience_builder\Entity\Component as ComponentEntity;
 use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem;
 use Symfony\Component\DependencyInjection\ContainerInterface;
-use Symfony\Component\Validator\ConstraintViolationList;
 
 /**
  * Defines a component source based on block plugins.
@@ -228,8 +227,8 @@ final class BlockComponent extends ComponentSourceBase implements ContainerFacto
   /**
    * {@inheritdoc}
    */
-  public function createPropsForComponent(string $component_instance_uuid, Component $component, array $client_props) {
-    return [$client_props, new ConstraintViolationList()];
+  public function createPropsForComponent(string $component_instance_uuid, Component $component, array $client_props): array {
+    return $client_props;
   }
 
 }
diff --git a/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php b/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php
index 6ffbb3fef7ef55e083dcf93452f225356c8646ce..238f4a54ff12deb939bb23689aee7fefccfd7b65 100644
--- a/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php
+++ b/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php
@@ -29,6 +29,7 @@ use Drupal\experience_builder\Attribute\ComponentSource;
 use Drupal\experience_builder\ComponentSource\ComponentSourceBase;
 use Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface;
 use Drupal\experience_builder\Entity\Component as ComponentEntity;
+use Drupal\experience_builder\Exception\ConstraintViolationException;
 use Drupal\experience_builder\InvalidRequestBodyValue;
 use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem;
 use Drupal\experience_builder\PropExpressions\Component\ComponentPropExpression;
@@ -618,7 +619,11 @@ final class SingleDirectoryComponent extends ComponentSourceBase implements Comp
       $props[$prop] = $updated_static_source;
     }
 
-    return [$props, $violation_list];
+    if ($violation_list->count()) {
+      throw new ConstraintViolationException($violation_list);
+    }
+
+    return $props;
   }
 
   /**
diff --git a/tests/src/Kernel/ClientDataToEntityConverterTest.php b/tests/src/Kernel/ClientDataToEntityConverterTest.php
index 1225d634d7334edbadaa0f33e7b93f1a47237697..7059fdf82ec602c2c4cd7182347b3c17125fb13f 100644
--- a/tests/src/Kernel/ClientDataToEntityConverterTest.php
+++ b/tests/src/Kernel/ClientDataToEntityConverterTest.php
@@ -5,11 +5,13 @@ declare(strict_types=1);
 namespace Drupal\Tests\experience_builder\Kernel;
 
 use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Entity\EntityConstraintViolationList;
 use Drupal\Core\Field\FieldItemListInterface;
 use Drupal\Core\Field\Plugin\Field\FieldWidget\StringTextfieldWidget;
 use Drupal\Core\Field\WidgetPluginManager;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\experience_builder\ClientDataToEntityConverter;
+use Drupal\experience_builder\Exception\ConstraintViolationException;
 use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\node\Entity\Node;
@@ -169,15 +171,19 @@ class ClientDataToEntityConverterTest extends KernelTestBase {
         }
       }
     }
-    $violations = $this->container->get(ClientDataToEntityConverter::class)->convert($client_json, $node);
-    $this->assertSame($node->id(), $violations->getEntity()->id());
-    $this->assertSame($expected_errors, self::violationsToArray($violations));
-    $this->assertSame($expected_title, (string) $node->getTitle());
-    if ($violations->count() === 0) {
+    try {
+      $this->container->get(ClientDataToEntityConverter::class)->convert($client_json, $node);
       // If no violations occurred, the node should be valid.
       $this->assertCount(0, $node->validate());
       $this->assertSame(SAVED_UPDATED, $node->save());
     }
+    catch (ConstraintViolationException $e) {
+      $violations = $e->getConstraintViolationList();
+      $this->assertInstanceOf(EntityConstraintViolationList::class, $violations);
+      $this->assertSame($node->id(), $violations->getEntity()->id());
+      $this->assertSame($expected_errors, self::violationsToArray($violations));
+      $this->assertSame($expected_title, (string) $node->getTitle());
+    }
 
     // Ensure the unchanged fields are not updated.
     // TRICKY: We can't directly compare `$client_json['entity_form_fields'][$field_name]`
diff --git a/tests/src/Kernel/ClientServerConversionTraitTest.php b/tests/src/Kernel/ClientServerConversionTraitTest.php
index 1a8a2fa99564652ff4fb1316e9aba4b0f57164af..6ff6b7987718159176f02268784cafd9ada28f6a 100644
--- a/tests/src/Kernel/ClientServerConversionTraitTest.php
+++ b/tests/src/Kernel/ClientServerConversionTraitTest.php
@@ -6,6 +6,7 @@ namespace Drupal\Tests\experience_builder\Kernel;
 
 use Drupal\experience_builder\Controller\ClientServerConversionTrait;
 use Drupal\experience_builder\Entity\Pattern;
+use Drupal\experience_builder\Exception\ConstraintViolationException;
 use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\node\Entity\Node;
@@ -47,9 +48,8 @@ class ClientServerConversionTraitTest extends KernelTestBase {
 
   public function testConvertClientToServer(): void {
     ['layout' => $layout, 'model' => $model] = $this->getValidClientJson();
-    [$tree, $props, $violations] = $this->convertClientToServer($layout, $model);
-    $this->assertCount(0, $violations);
-    $this->assertSame($this->getValidConvertedProps(), $props);
+    $converted_item = $this->convertClientToServer($layout, $model);
+    $this->assertSame($this->getValidConvertedProps(), json_decode($converted_item['props'], TRUE));
     $this->assertSame([
       ComponentTreeStructure::ROOT_UUID => [
         [
@@ -61,12 +61,7 @@ class ClientServerConversionTraitTest extends KernelTestBase {
           'component' => 'sdc.experience_builder.image',
         ],
       ],
-    ], $tree);
-
-    $converted_item = [
-      'tree' => self::encodeXBData($tree),
-      'props' => self::encodeXBData($props),
-    ];
+    ], json_decode($converted_item['tree'], TRUE));
 
     // Ensure convert 'tree' and 'props' can be used both to create both a
     // config entity and a content entity field value.
@@ -122,10 +117,13 @@ class ClientServerConversionTraitTest extends KernelTestBase {
   }
 
   private function assertConversionErrors(array $client_json, array $errors): void {
-    [$tree, $props, $violations] = $this->convertClientToServer($client_json['layout'], $client_json['model']);
-    $this->assertNull($tree);
-    $this->assertNull($props);
-    $this->assertSame($errors, $this->violationsToArray($violations));
+    try {
+      $this->convertClientToServer($client_json['layout'], $client_json['model']);
+      $this->fail();
+    }
+    catch (ConstraintViolationException $e) {
+      $this->assertSame($errors, $this->violationsToArray($e->getConstraintViolationList()));
+    }
   }
 
 }