diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index c9cc542b27cbc0c2a14e6195d23dcc68bd389add..e30836c72e59fdfabee40f8479a6a7c7238e4fbf 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -39,6 +39,13 @@ include:
       - '/includes/include.drupalci.variables.yml'
       - '/includes/include.drupalci.workflows.yml'
 
+stages:
+  - development
+  - build
+  - validate
+  - test
+  - publish
+
 ################
 # Pipeline configuration variables
 #
@@ -59,6 +66,49 @@ variables:
   OPT_IN_TEST_NEXT_MINOR: 1
   OPT_IN_TEST_MAX_PHP: 1
 
+# @todo Remove when https://www.drupal.org/project/gitlab_templates/issues/3452183 lands.
+.is-push-rule: &is-push-rule
+  if: $CI_PIPELINE_SOURCE == "push" && $CI_PROJECT_ROOT_NAMESPACE == "project"
+
+.manual-rule: &manual-rule
+  - when: manual
+    allow_failure: true
+
+# @todo Remove when https://www.drupal.org/project/gitlab_templates/issues/3452183 lands.
+.php-files-change-rule: &php-files-change-rule
+  - changes:
+      - "**/*.{php,module,inc,install,theme,profile}"
+    when: on_success
+
+# @todo Remove when https://www.drupal.org/project/gitlab_templates/issues/3452183 lands.
+.css-files-change-rule: &css-files-change-rule
+  - changes:
+      - "**/*.css"
+    when: on_success
+
+# @todo Remove when https://www.drupal.org/project/gitlab_templates/issues/3452183 lands.
+.module-metadata-files-change-rule: &module-metadata-files-change-rule
+  - changes:
+      - "**/*.yml"
+    when: on_success
+
+# @todo Remove when https://www.drupal.org/project/gitlab_templates/issues/3452183 lands.
+.module-config-files-change-rule: &module-config-files-change-rule
+  - changes:
+      # config schema and/or default config
+      - config
+    when: on_success
+
+# @todo Remove when https://www.drupal.org/project/gitlab_templates/issues/3452183 lands.
+composer-lint:
+  rules:
+    - *is-push-rule
+    - *php-files-change-rule
+    - changes:
+        - composer.json
+      when: on_success
+    - *manual-rule
+
 ################
 # Require composer checks to pass.
 ################
@@ -84,6 +134,50 @@ stylelint:
 ################
 phpcs:
   allow_failure: false
+  # @todo Remove when https://www.drupal.org/project/gitlab_templates/issues/3452183 lands.
+  rules:
+    - *is-push-rule
+    - *php-files-change-rule
+    # Also run whenever we catch up to the upstream PHPCS rules.
+    - changes:
+        - core.phpcs.xml.dist
+      when: on_success
+    - *manual-rule
+
+phpcs-rules-match-drupal-11:
+  stage: development
+  script:
+    - |
+      # Download the file from the Drupal repository
+      curl -o drupal_core_phpcs.xml.dist https://git.drupalcode.org/project/drupal/-/raw/11.x/core/phpcs.xml.dist?ref_type=heads
+
+      # Check if the download was successful
+      if [ $? -ne 0 ]; then
+        echo "🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥"
+        echo "Error: Failed to download $DRUPAL_URL"
+        exit 1
+      fi
+
+      # Compare the downloaded file with the local file using a subshell to prevent premature exit
+      echo "Comparing downloaded file with local file..."
+      # With uses the braces the job just ends here for some reason.
+       {
+        diff drupal_core_phpcs.xml.dist core.phpcs.xml.dist
+        DIFF_EXIT_CODE=$?
+      } || true
+
+      if [ $DIFF_EXIT_CODE -ne 0 ]; then
+        echo "🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥"
+        echo "Error: core.phpcs.xml.dist not up to date with Drupal 11.x core/phpcs.xml.dist. Please create an issue to update this file."
+      else
+        echo "👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍"
+        echo "core.phpcs.xml.dist is up to date with Drupal 11.x core/phpcs.xml.dist"
+      fi
+
+      # Exit with the exit code of the diff command
+      exit $DIFF_EXIT_CODE
+  # Allow this to fail, to avoid disrupting XB's CI when upstream changes.
+  allow_failure: true
 
 ################
 # Require phpstan checks to pass.
diff --git a/core.phpcs.xml.dist b/core.phpcs.xml.dist
new file mode 100644
index 0000000000000000000000000000000000000000..b8902c685c702b3457c35a0afd482ab7cabc99b2
--- /dev/null
+++ b/core.phpcs.xml.dist
@@ -0,0 +1,422 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ruleset name="drupal_core">
+  <arg name="extensions" value="engine,inc,install,module,php,profile,test,theme,yml"/>
+  <description>Default PHP CodeSniffer configuration for Drupal core.</description>
+
+  <!--Exclude folders used by common frontend tools. These folders match the file_scan_ignore_directories setting in default.settings.php-->
+  <exclude-pattern>*/bower_components/*</exclude-pattern>
+  <exclude-pattern>*/node_modules/*</exclude-pattern>
+  <!--Exclude third party code.-->
+  <exclude-pattern>./assets/vendor/*</exclude-pattern>
+  <!--Exclude the PHPStan baseline and temp dir from coding standards.-->
+  <exclude-pattern>./core/.phpstan-baseline.php</exclude-pattern>
+  <!-- Exclude third-party code maintained within core that does not follow our standards. -->
+  <!-- @todo This rule may be removed when https://www.drupal.org/node/1848264 is resolved. -->
+  <exclude-pattern>./core/lib/Drupal/Component/Diff/</exclude-pattern>
+  <exclude-pattern>./core/phpstan-tmp/*</exclude-pattern>
+  <exclude-pattern>./core/tests/Drupal/Tests/Component/Annotation/Doctrine/</exclude-pattern>
+
+  <!--Exclude test files that are intentionally empty, or intentionally violate coding standards.-->
+  <exclude-pattern>./modules/system/tests/fixtures/HtaccessTest</exclude-pattern>
+
+  <file>.</file>
+  <file>../composer</file>
+  <file>scripts/password-hash.sh</file>
+  <file>scripts/rebuild_token_calculator.sh</file>
+  <file>scripts/run-tests.sh</file>
+  <file>scripts/update-countries.sh</file>
+
+  <!-- Only include specific sniffs that pass. This ensures that, if new sniffs are added, HEAD does not fail.-->
+
+  <!-- Drupal sniffs -->
+  <rule ref="Drupal.Arrays.Array">
+    <!-- Sniff for these errors: ArrayClosingIndentation, ArrayIndentation, CommaLastItem -->
+    <exclude name="Drupal.Arrays.Array.LongLineDeclaration"/>
+  </rule>
+  <rule ref="Drupal.CSS.ClassDefinitionNameSpacing"/>
+  <rule ref="Drupal.CSS.ColourDefinition"/>
+  <rule ref="Drupal.Classes.ClassCreateInstance"/>
+  <rule ref="Drupal.Classes.ClassDeclaration"/>
+  <rule ref="Drupal.Classes.ClassFileName"/>
+  <rule ref="Drupal.Classes.FullyQualifiedNamespace"/>
+  <rule ref="Drupal.Classes.InterfaceName"/>
+  <rule ref="Drupal.Classes.PropertyDeclaration"/>
+  <rule ref="Drupal.Classes.UnusedUseStatement"/>
+  <rule ref="Drupal.Classes.UseGlobalClass"/>
+  <rule ref="Drupal.Classes.UseLeadingBackslash"/>
+  <rule ref="Drupal.Commenting.ClassComment"/>
+  <rule ref="Drupal.Commenting.ClassComment.Missing">
+    <include-pattern>*/Functional/*</include-pattern>
+  </rule>
+  <rule ref="Drupal.Commenting.DataTypeNamespace"/>
+  <rule ref="Drupal.Commenting.Deprecated"/>
+  <rule ref="Drupal.Commenting.DocComment">
+    <!-- Sniff for these errors: SpacingAfterTagGroup, WrongEnd, SpacingBetween,
+      ContentAfterOpen, SpacingBeforeShort, TagValueIndent, ShortStartSpace,
+      SpacingAfter, LongNotCapital, ShortFullStop, TagGroupSpacing, Empty,
+      TagsNotGrouped, ParamGroup -->
+    <exclude name="Drupal.Commenting.DocComment.LongFullStop"/>
+    <exclude name="Drupal.Commenting.DocComment.MissingShort"/>
+    <exclude name="Drupal.Commenting.DocComment.ShortNotCapital"/>
+  </rule>
+  <rule ref="Drupal.Commenting.DocCommentAlignment"/>
+  <rule ref="Drupal.Commenting.DocCommentLongArraySyntax"/>
+  <rule ref="Drupal.Commenting.DocCommentStar"/>
+  <rule ref="Drupal.Commenting.FileComment"/>
+  <rule ref="Drupal.Commenting.FunctionComment">
+    <exclude name="Drupal.Commenting.FunctionComment.ParamCommentFullStop"/>
+  </rule>
+  <rule ref="Drupal.Commenting.FunctionComment.Missing">
+    <include-pattern>core/modules/*/Plugin/views/argument/*</include-pattern>
+    <include-pattern>core/modules/*/Plugin/views/filter/*</include-pattern>
+    <include-pattern>core/modules/*/Plugin/views/access/*</include-pattern>
+    <include-pattern>core/modules/*/Plugin/views/cache/*</include-pattern>
+    <include-pattern>core/modules/*/Plugin/views/query/*</include-pattern>
+    <include-pattern>core/modules/*/Plugin/views/sort/*</include-pattern>
+    <include-pattern>core/modules/*/Plugin/views/display/*</include-pattern>
+    <include-pattern>core/modules/*/Plugin/views/exposed_form/*</include-pattern>
+    <include-pattern>core/modules/*/Plugin/views/field/*</include-pattern>
+    <include-pattern>core/modules/*/Plugin/views/pager/*</include-pattern>
+    <include-pattern>core/modules/*/Plugin/views/style/*</include-pattern>
+    <include-pattern>*/Database/*</include-pattern>
+    <include-pattern>*/FunctionalJavascript/*</include-pattern>
+  </rule>
+  <rule ref="Drupal.Commenting.FunctionComment.MissingParamType"/>
+  <rule ref="Drupal.Commenting.FunctionComment.MissingReturnComment">
+    <include-pattern>core/lib/Drupal/Core/*</include-pattern>
+    <include-pattern>core/lib/Drupal/Component/*</include-pattern>
+    <include-pattern>core/tests/*</include-pattern>
+  </rule>
+  <rule ref="Drupal.Commenting.FunctionComment.MissingReturnComment">
+    <exclude-pattern>core/lib/Drupal/Core/*</exclude-pattern>
+    <exclude-pattern>core/lib/Drupal/Component/*</exclude-pattern>
+    <exclude-pattern>core/tests/*</exclude-pattern>
+    <exclude-pattern>core/*/tests/*</exclude-pattern>
+  </rule>
+  <rule ref="Drupal.Commenting.GenderNeutralComment"/>
+  <rule ref="Drupal.Commenting.HookComment"/>
+  <rule ref="Drupal.Commenting.InlineComment">
+    <!-- Sniff for: NoSpaceBefore, SpacingBefore, WrongStyle -->
+    <exclude name="Drupal.Commenting.InlineComment.DocBlock"/>
+    <exclude name="Drupal.Commenting.InlineComment.InvalidEndChar"/>
+    <exclude name="Drupal.Commenting.InlineComment.SpacingAfter"/>
+  </rule>
+  <rule ref="Drupal.Commenting.InlineVariableComment"/>
+  <rule ref="Drupal.Commenting.PostStatementComment"/>
+  <rule ref="Drupal.Commenting.TodoComment" />
+  <rule ref="Drupal.Commenting.VariableComment"/>
+  <rule ref="Drupal.ControlStructures.ControlSignature"/>
+  <rule ref="Drupal.ControlStructures.ElseIf"/>
+  <rule ref="Drupal.ControlStructures.InlineControlStructure"/>
+  <rule ref="Drupal.Files.EndFileNewline"/>
+  <rule ref="Drupal.Files.FileEncoding"/>
+  <rule ref="Drupal.Files.TxtFileLineLength"/>
+  <rule ref="Drupal.Formatting.MultiLineAssignment"/>
+  <rule ref="Drupal.Formatting.MultipleStatementAlignment"/>
+  <rule ref="Drupal.Formatting.SpaceInlineIf"/>
+  <rule ref="Drupal.Formatting.SpaceUnaryOperator"/>
+  <rule ref="Drupal.Functions.DiscouragedFunctions"/>
+  <rule ref="Drupal.Functions.FunctionDeclaration"/>
+  <rule ref="Drupal.Functions.MultiLineFunctionDeclaration"/>
+  <rule ref="Drupal.InfoFiles.AutoAddedKeys"/>
+  <rule ref="Drupal.InfoFiles.ClassFiles"/>
+  <rule ref="Drupal.InfoFiles.DuplicateEntry"/>
+  <rule ref="Drupal.InfoFiles.Required"/>
+  <rule ref="Drupal.Methods.MethodDeclaration">
+    <!-- Silence method name underscore warning which is covered already in
+      Drupal.NamingConventions.ValidFunctionName.ScopeNotCamelCaps. -->
+    <exclude name="Drupal.Methods.MethodDeclaration.Underscore"/>
+  </rule>
+  <rule ref="Drupal.NamingConventions.ValidClassName"/>
+  <rule ref="Drupal.NamingConventions.ValidGlobal"/>
+  <rule ref="Drupal.NamingConventions.ValidVariableName"/>
+  <rule ref="Drupal.NamingConventions.ValidVariableName.LowerCamelName"/>
+  <rule ref="Drupal.Scope.MethodScope"/>
+  <rule ref="Drupal.Semantics.EmptyInstall"/>
+  <rule ref="Drupal.Semantics.FunctionAlias"/>
+  <rule ref="Drupal.Semantics.FunctionT"/>
+  <rule ref="Drupal.Semantics.FunctionTriggerError"/>
+  <rule ref="Drupal.Semantics.FunctionWatchdog"/>
+  <rule ref="Drupal.Semantics.InstallHooks"/>
+  <rule ref="Drupal.Semantics.LStringTranslatable"/>
+  <rule ref="Drupal.Semantics.PregSecurity"/>
+  <rule ref="Drupal.Semantics.RemoteAddress"/>
+  <rule ref="Drupal.Semantics.TInHookMenu"/>
+  <rule ref="Drupal.Semantics.TInHookSchema"/>
+  <rule ref="Drupal.Strings.UnnecessaryStringConcat"/>
+  <rule ref="Drupal.WhiteSpace.CloseBracketSpacing"/>
+  <rule ref="Drupal.WhiteSpace.Comma"/>
+  <rule ref="Drupal.WhiteSpace.EmptyLines"/>
+  <rule ref="Drupal.WhiteSpace.Namespace"/>
+  <rule ref="Drupal.WhiteSpace.ObjectOperatorIndent"/>
+  <rule ref="Drupal.WhiteSpace.ObjectOperatorSpacing"/>
+  <rule ref="Drupal.WhiteSpace.OpenBracketSpacing"/>
+  <rule ref="Drupal.WhiteSpace.OpenTagNewline"/>
+  <rule ref="Drupal.WhiteSpace.ScopeClosingBrace"/>
+  <rule ref="Drupal.WhiteSpace.ScopeIndent"/>
+
+  <!-- Drupal Practice sniffs -->
+  <rule ref="DrupalPractice.CodeAnalysis.VariableAnalysis">
+    <!-- Do not run this sniff on API files or transliteration data. -->
+    <exclude-pattern>*.api.php</exclude-pattern>
+    <exclude-pattern>core/lib/Drupal/Component/Transliteration/data/*.php</exclude-pattern>
+    <properties>
+      <property name="allowUnusedFunctionParameters" value="true"/>
+    </properties>
+  </rule>
+  <rule ref="DrupalPractice.CodeAnalysis.VariableAnalysis.UndefinedUnsetVariable">
+    <severity>0</severity>
+  </rule>
+  <rule ref="DrupalPractice.CodeAnalysis.VariableAnalysis.UndefinedVariable">
+    <!-- Setting severity to 0 to completely disable an error message in this sniff, without excluding the whole sniff -->
+    <!-- See https://github.com/squizlabs/PHP_CodeSniffer/wiki/Configuration-Options#changing-the-default-severity-levels -->
+    <severity>0</severity>
+  </rule>
+  <rule ref="DrupalPractice.Commenting.ExpectedException"/>
+  <rule ref="DrupalPractice.General.ExceptionT"/>
+  <rule ref="DrupalPractice.InfoFiles.NamespacedDependency"/>
+  <rule ref="DrupalPractice.Objects.GlobalFunction">
+    <include-pattern>*/Plugin/*</include-pattern>
+    <include-pattern>*/ListBuilder/*</include-pattern>
+    <include-pattern>*/tests/*</include-pattern>
+    <include-pattern>./core/lib/*</include-pattern>
+    <include-pattern>./core/modules/*</include-pattern>
+    <include-pattern>*/Hook/*</include-pattern>
+  </rule>
+
+  <!-- Generic sniffs -->
+  <rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
+  <rule ref="Generic.CodeAnalysis.EmptyPHPStatement"/>
+  <rule ref="Generic.Files.ByteOrderMark"/>
+  <rule ref="Generic.Files.LineEndings"/>
+  <rule ref="Generic.Formatting.DisallowMultipleStatements"/>
+  <rule ref="Generic.Formatting.SpaceAfterCast"/>
+  <rule ref="Generic.Functions.FunctionCallArgumentSpacing"/>
+  <rule ref="Generic.NamingConventions.ConstructorName"/>
+  <rule ref="Generic.NamingConventions.UpperCaseConstantName"/>
+  <rule ref="Generic.PHP.DeprecatedFunctions"/>
+  <rule ref="Generic.PHP.DisallowShortOpenTag"/>
+  <rule ref="Generic.PHP.LowerCaseKeyword"/>
+  <rule ref="Generic.PHP.UpperCaseConstant"/>
+  <rule ref="Generic.WhiteSpace.DisallowTabIndent"/>
+
+  <!-- Internal sniffs -->
+  <rule ref="Internal.NoCodeFound">
+    <!-- No PHP code in *.yml -->
+    <exclude-pattern>*.yml</exclude-pattern>
+  </rule>
+
+  <!-- MySource sniffs -->
+  <rule ref="MySource.Debug.DebugCode"/>
+
+  <!-- PEAR sniffs -->
+  <rule ref="PEAR.Files.IncludingFile"/>
+  <!-- Disable some error messages that we do not want. -->
+  <rule ref="PEAR.Files.IncludingFile.UseInclude">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Files.IncludingFile.UseIncludeOnce">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Files.IncludingFile.UseRequire">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Files.IncludingFile.UseRequireOnce">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Functions.FunctionCallSignature"/>
+  <!-- The sniffs inside PEAR.Functions.FunctionCallSignature silenced below are
+    also silenced in Drupal CS' ruleset.xml. The code below is a 1-on-1 copy
+    from that file. -->
+  <rule ref="PEAR.Functions.FunctionCallSignature.CloseBracketLine">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Functions.FunctionCallSignature.ContentAfterOpenBracket">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Functions.FunctionCallSignature.EmptyLine">
+    <severity>0</severity>
+  </rule>
+  <!-- Disable some error messages that we do not want. -->
+  <rule ref="PEAR.Functions.FunctionCallSignature.Indent">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Functions.FunctionCallSignature.OpeningIndent">
+    <severity>0</severity>
+  </rule>
+  <!-- Disable some error messages that we already cover. -->
+  <rule ref="PEAR.Functions.FunctionCallSignature.SpaceAfterOpenBracket">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Functions.FunctionCallSignature.SpaceBeforeCloseBracket">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Functions.ValidDefaultValue"/>
+
+  <!-- PSR-2 sniffs -->
+  <rule ref="PSR2.Classes.PropertyDeclaration">
+     <!-- Silence method name underscore warning which is covered already in
+       Drupal.Classes.PropertyDeclaration. -->
+    <exclude name="PSR2.Classes.PropertyDeclaration.Underscore"/>
+  </rule>
+  <rule ref="PSR2.Namespaces.NamespaceDeclaration"/>
+  <rule ref="PSR2.Namespaces.UseDeclaration"/>
+
+  <!-- SlevomatCodingStandard sniffs -->
+  <rule ref="SlevomatCodingStandard.Classes.BackedEnumTypeSpacing"/>
+  <rule ref="SlevomatCodingStandard.Commenting.ForbiddenAnnotations">
+    <properties>
+      <property name="forbiddenAnnotations" type="array">
+        <element value="@inheritDoc"/>
+        <element value="@inheritdoc"/>
+      </property>
+    </properties>
+  </rule>
+  <rule ref="SlevomatCodingStandard.Commenting.ForbiddenComments">
+    <properties>
+       <property name="forbiddenCommentPatterns" type="array">
+         <element value="/@inheritDoc/"/>
+       </property>
+    </properties>
+  </rule>
+  <rule ref="SlevomatCodingStandard.ControlStructures.RequireNullCoalesceOperator"/>
+  <rule ref="SlevomatCodingStandard.ControlStructures.RequireShortTernaryOperator"/>
+  <rule ref="SlevomatCodingStandard.Exceptions.RequireNonCapturingCatch" />
+  <rule ref="SlevomatCodingStandard.TypeHints.DeclareStrictTypes">
+    <properties>
+      <property name="spacesCountAroundEqualsSign" value="0" />
+    </properties>
+    <include-pattern>*/tests/*</include-pattern>
+    <exclude-pattern>*/fixtures/*</exclude-pattern>
+  </rule>
+  <rule ref="SlevomatCodingStandard.TypeHints.NullableTypeForNullDefaultValue"/>
+
+  <!-- Squiz sniffs -->
+  <rule ref="Squiz.Arrays.ArrayBracketSpacing"/>
+  <rule ref="Squiz.Arrays.ArrayDeclaration">
+    <exclude name="Squiz.Arrays.ArrayDeclaration.KeySpecified"/>
+    <exclude name="Squiz.Arrays.ArrayDeclaration.NoKeySpecified"/>
+  </rule>
+  <!-- Disable some error messages that we do not want. -->
+  <rule ref="Squiz.Arrays.ArrayDeclaration.CloseBraceNotAligned">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Arrays.ArrayDeclaration.DoubleArrowNotAligned">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Arrays.ArrayDeclaration.FirstValueNoNewline">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Arrays.ArrayDeclaration.KeyNotAligned">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Arrays.ArrayDeclaration.MultiLineNotAllowed">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Arrays.ArrayDeclaration.NoComma">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Arrays.ArrayDeclaration.NoCommaAfterLast">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Arrays.ArrayDeclaration.NotLowerCase">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Arrays.ArrayDeclaration.SingleLineNotAllowed">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Arrays.ArrayDeclaration.ValueNoNewline">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Arrays.ArrayDeclaration.ValueNotAligned">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration"/>
+  <!-- Disable some error messages that we already cover. -->
+  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration.AsNotLower">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration.SpaceAfterOpen">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration.SpaceBeforeClose">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.ForLoopDeclaration"/>
+  <!-- Disable some error messages that we already cover. -->
+  <rule ref="Squiz.ControlStructures.ForLoopDeclaration.SpacingAfterOpen">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.ForLoopDeclaration.SpacingBeforeClose">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration"/>
+  <!-- Disable some error messages that we do not want. -->
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration.BreakIndent">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration.CaseIndent">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration.CloseBraceAlign">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration.DefaultIndent">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration.DefaultNoBreak">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration.EmptyCase">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration.EmptyDefault">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration.MissingDefault">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration.SpacingAfterCase">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration.SpacingAfterDefaultBreak">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.ControlStructures.SwitchDeclaration.SpacingBeforeBreak">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Functions.FunctionDeclarationArgumentSpacing">
+    <properties>
+      <property name="equalsSpacing" value="1"/>
+    </properties>
+  </rule>
+  <rule ref="Squiz.Functions.FunctionDeclarationArgumentSpacing.NoSpaceBeforeArg">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.PHP.LowercasePHPFunctions"/>
+  <rule ref="Squiz.PHP.NonExecutableCode"/>
+  <rule ref="Squiz.Strings.ConcatenationSpacing">
+    <properties>
+      <property name="spacing" value="1"/>
+      <property name="ignoreNewlines" value="true"/>
+    </properties>
+  </rule>
+  <rule ref="Squiz.WhiteSpace.FunctionSpacing">
+    <properties>
+      <property name="spacing" value="1"/>
+    </properties>
+  </rule>
+  <rule ref="Squiz.WhiteSpace.LanguageConstructSpacing"/>
+  <rule ref="Squiz.WhiteSpace.OperatorSpacing">
+    <properties>
+      <property name="ignoreNewlines" value="true"/>
+    </properties>
+  </rule>
+  <rule ref="Squiz.WhiteSpace.ScopeKeywordSpacing"/>
+  <rule ref="Squiz.WhiteSpace.SemicolonSpacing"/>
+  <rule ref="Squiz.WhiteSpace.SuperfluousWhitespace"/>
+
+  <!-- Zend sniffs -->
+  <rule ref="Zend.Files.ClosingTag"/>
+
+</ruleset>
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000000000000000000000000000000000000..92f1b9ce1bddb2e00c1f04c273d9e84aadaed118
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ruleset name="project_browser">
+  <!-- Drupal (core) uses stylelint: https://www.drupal.org/list-changes/drupal/published?keywords_description=stylelint-->
+  <exclude-pattern>*.css</exclude-pattern>
+  <!-- Drupal (core) uses eslint: https://www.drupal.org/list-changes/drupal/published?keywords_description=eslint-->
+  <exclude-pattern>*.js</exclude-pattern>
+
+  <!-- Use the Drupal core coding standard -->
+  <rule ref="./core.phpcs.xml.dist"></rule>
+</ruleset>
diff --git a/tests/modules/project_browser_test/src/TestInstallReadiness.php b/tests/modules/project_browser_test/src/TestInstallReadiness.php
index bc17893aee871f02adb63d39e16bbd6a7175d31b..4823c2b75dc1fd72a99b0403170c87c3242e50dd 100644
--- a/tests/modules/project_browser_test/src/TestInstallReadiness.php
+++ b/tests/modules/project_browser_test/src/TestInstallReadiness.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Drupal\project_browser_test;
 
 use Drupal\Core\State\StateInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\package_manager\Event\StatusCheckEvent;
 use Drupal\system\SystemManager;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@@ -42,12 +43,12 @@ class TestInstallReadiness implements EventSubscriberInterface {
 
     if ($severity === SystemManager::REQUIREMENT_ERROR) {
       $event->addError([
-        t('Simulate an error message for the project browser.'),
+        new TranslatableMarkup('Simulate an error message for the project browser.'),
       ]);
     }
     elseif ($severity === SystemManager::REQUIREMENT_WARNING) {
       $event->addWarning([
-        t('Simulate a warning message for the project browser.'),
+        new TranslatableMarkup('Simulate a warning message for the project browser.'),
       ]);
     }
   }
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index 055670422d2ae85d0c4f11c6ac920c60e5c9c57e..cfe51d95b17c4e8fd5df35685ceb6e9f52a217c0 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Drupal\Tests\project_browser\Functional;
 
 use Drupal\Component\Serialization\Json;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\Core\Url;
 use Drupal\Tests\ApiRequestTrait;
 use Drupal\Tests\BrowserTestBase;
@@ -197,6 +198,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->stageId = Json::decode($contents)['stage_id'];
     $this->assertSession()->statusCodeEquals(200);
     $expected_output = sprintf('{"phase":"create","status":0,"stage_id":"%s"}', $this->stageId);
+    $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
   }
 
   /**
@@ -208,8 +210,8 @@ class InstallerControllerTest extends BrowserTestBase {
     $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/awesome_module', [
       'stage_id' => $this->stageId,
     ]);
-    $expected_output = sprintf('{"phase":"create","status":0,"stage_id":"%s"}', $this->stageId);
-    $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
+    $expected_output = sprintf('{"phase":"require","status":0,"stage_id":"%s"}', $this->stageId);
+    $this->assertSame($expected_output, (string) $response->getBody());
     $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'requiring');
   }
 
@@ -266,7 +268,7 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::create
    */
   public function testPreCreateError(): void {
-    $message = t('This is a PreCreate error.');
+    $message = new TranslatableMarkup('This is a PreCreate error.');
     $result = ValidationResult::createError([$message]);
     TestSubscriber::setTestResult([$result], PreCreateEvent::class);
     $contents = $this->drupalGet('admin/modules/project_browser/install-begin');
@@ -306,7 +308,7 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::require
    */
   public function testPreRequireError(): void {
-    $message = t('This is a PreRequire error.');
+    $message = new TranslatableMarkup('This is a PreRequire error.');
     $result = ValidationResult::createError([$message]);
     $this->doStart();
     TestSubscriber::setTestResult([$result], PreRequireEvent::class);
@@ -355,7 +357,7 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::apply
    */
   public function testPreApplyError(): void {
-    $message = t('This is a PreApply error.');
+    $message = new TranslatableMarkup('This is a PreApply error.');
     $result = ValidationResult::createError([$message]);
     TestSubscriber::setTestResult([$result], PreApplyEvent::class);
     $this->doStart();
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 78cef31cc9e9a8289982d5687fc39a081c5baa2c..2e1560123835489e3d8b2e0fbcb7465554334375 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -8,7 +8,6 @@ use Behat\Mink\Element\NodeElement;
 use Drupal\Core\Recipe\Recipe;
 use Drupal\Core\State\StateInterface;
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
-use Drupal\project_browser\EnabledSourceHandler;
 use Drupal\project_browser\InstallState;
 use Drupal\project_browser_test\TestActivator;
 use Drupal\system\SystemManager;
@@ -80,7 +79,7 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     TestActivator::handle('drupal/cream_cheese');
 
     $assert_session = $this->assertSession();
-    $page = $this->getSession()->getPage();
+
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
     $this->svelteInitHelper('text', 'Cream cheese on a bagel');
     $cream_cheese_module_selector = '#project-browser .pb-layout__main ul > li:nth-child(1)';
@@ -106,7 +105,7 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    */
   public function testInstallModuleAlreadyInFilesystem(): void {
     $assert_session = $this->assertSession();
-    $page = $this->getSession()->getPage();
+
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
     $this->svelteInitHelper('text', 'Pinky and the Brain');
     $pinky_brain_selector = '#project-browser .pb-layout__main ul > li:nth-child(2)';
@@ -129,9 +128,6 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    * Tests applying a recipe from the project browser UI.
    */
   public function testApplyRecipe(): void {
-    if (!class_exists(Recipe::class)) {
-      $this->markTestSkipped('This test cannot run because this version of Drupal does not support recipes.');
-    }
     $page = $this->getSession()->getPage();
     $assert_session = $this->assertSession();
 
@@ -263,9 +259,6 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $assert_session = $this->assertSession();
     $page = $this->getSession()->getPage();
 
-    // Find a project we can install.
-    $project_id = $this->chooseProjectToInstall(['cream_cheese']);
-
     // Start install begin.
     $this->drupalGet('admin/modules/project_browser/install-begin', [
       'query' => ['source' => 'project_browser_test_mock'],
@@ -321,7 +314,7 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
       ->set('project_browser_test.simulated_result_severity', SystemManager::REQUIREMENT_WARNING);
 
     $assert_session = $this->assertSession();
-    $page = $this->getSession()->getPage();
+
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
     $this->svelteInitHelper('text', 'Cream cheese on a bagel');
     $cream_cheese_module_selector = '#project-browser .pb-layout__main ul > li:nth-child(1)';
@@ -339,31 +332,6 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $assert_session->pageTextContains('Simulate a warning message for the project browser.');
   }
 
-  /**
-   * Finds a project from the provided source that can be installed.
-   *
-   * @param string[] $except_these_machine_names
-   *   Project machine names that should be ignored.
-   * @param string $source_id
-   *   The ID of the source to query for projects.
-   *
-   * @return string
-   *   The project ID to use.
-   */
-  private function chooseProjectToInstall(array $except_these_machine_names = [], string $source_id = 'project_browser_test_mock'): string {
-    $handler = $this->container->get(EnabledSourceHandler::class);
-    $results = $handler->getProjects($source_id);
-
-    foreach ($results->list as $project) {
-      if (in_array($project->machineName, $except_these_machine_names, TRUE)) {
-        continue;
-      }
-      return $project->id;
-    }
-
-    $this->fail("Could not find a project to install from amongst the enabled sources.");
-  }
-
   /**
    * Tests the "Install selected projects" button functionality.
    */
@@ -447,7 +415,7 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    */
   public function testUnlockLinkMarkup(): void {
     $assert_session = $this->assertSession();
-    $page = $this->getSession()->getPage();
+
     $this->drupalGet('admin/modules/project_browser/install-begin', [
       'query' => ['source' => 'project_browser_test_mock'],
     ]);
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
index 66f6270a28a175f0dd930ef2249ccd59651dde0b..fa833827f2c6da5c7156f879139bb24ea6956c8f 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
@@ -6,7 +6,6 @@ namespace Drupal\Tests\project_browser\FunctionalJavascript;
 
 use Behat\Mink\Element\NodeElement;
 use Drupal\Core\Extension\MissingDependencyException;
-use Drupal\Core\Recipe\Recipe;
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
 use Drupal\project_browser\EnabledSourceHandler;
 
@@ -1114,9 +1113,6 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
    * Tests that recipes show instructions for applying them.
    */
   public function testRecipeInstructions(): void {
-    if (!class_exists(Recipe::class)) {
-      $this->markTestSkipped('This test cannot run because this version of Drupal does not support recipes.');
-    }
     $assert_session = $this->assertSession();
 
     $this->config('project_browser.admin_settings')
diff --git a/tests/src/Kernel/InstallerTest.php b/tests/src/Kernel/InstallerTest.php
index 04cc7ab648637dda92d847f309c95f6cd5291c10..01775be4bf3d9ebc1d2058d0b19da63d715d92b4 100644
--- a/tests/src/Kernel/InstallerTest.php
+++ b/tests/src/Kernel/InstallerTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\project_browser\Kernel;
 
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
 use Drupal\Tests\package_manager\Traits\ComposerStagerTestTrait;
 use Drupal\Tests\user\Traits\UserCreationTrait;
@@ -120,7 +121,7 @@ class InstallerTest extends PackageManagerKernelTestBase {
     $installer->create();
     $installer->require(['org/package-name']);
     $results = [
-      ValidationResult::createError([t('These are not the projects you are looking for.')]),
+      ValidationResult::createError([new TranslatableMarkup('These are not the projects you are looking for.')]),
     ];
     TestSubscriber::setTestResult($results, PreApplyEvent::class);
     $this->expectException(StageEventException::class);
diff --git a/tests/src/Kernel/RecipesSourceTest.php b/tests/src/Kernel/RecipesSourceTest.php
index e167d58bcc63ba2fd74faaa5bba3a67ba42a33ec..5c5bd3ffa8ef96d3b2c010c9bd4b304f46ebd358 100644
--- a/tests/src/Kernel/RecipesSourceTest.php
+++ b/tests/src/Kernel/RecipesSourceTest.php
@@ -5,7 +5,6 @@ declare(strict_types=1);
 namespace Drupal\Tests\project_browser\Kernel;
 
 use Drupal\Component\FileSystem\FileSystem;
-use Drupal\Core\Recipe\Recipe;
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\project_browser\Plugin\ProjectBrowserSourceManager;
 use Drupal\project_browser\ProjectType;
@@ -31,9 +30,6 @@ class RecipesSourceTest extends KernelTestBase {
   protected function setUp(): void {
     parent::setUp();
 
-    if (!class_exists(Recipe::class)) {
-      $this->markTestSkipped('This test cannot be run because the recipe system is not available.');
-    }
     $this->installSchema('project_browser_test', [
       'project_browser_projects',
       'project_browser_categories',
@@ -175,10 +171,7 @@ class RecipesSourceTest extends KernelTestBase {
     $this->setSetting('project_browser_recipe_directories', [$installed_recipes_dir]);
 
     // Fetch discovered recipes.
-    /** @var \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage $projects */
-    $projects = $this->container->get(ProjectBrowserSourceManager::class)
-      ->createInstance('recipes')
-      ->getProjects();
+    $projects = $source->getProjects();
     $found_recipes = array_column($projects->list, 'title');
 
     $generated_recipe_titles = array_keys($generated_recipes);