diff --git a/core/composer.json b/core/composer.json
index 810ec27e59..75af602e33 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -110,6 +110,7 @@
         "drupal/core-proxy-builder": "self.version",
         "drupal/core-render": "self.version",
         "drupal/core-serialization": "self.version",
+        "drupal/core-composer-scaffold": "self.version",
         "drupal/core-transliteration": "self.version",
         "drupal/core-utility": "self.version",
         "drupal/core-uuid": "self.version",
@@ -200,6 +201,7 @@
                 "core/lib/Drupal/Component/ProxyBuilder/composer.json",
                 "core/lib/Drupal/Component/Render/composer.json",
                 "core/lib/Drupal/Component/Serialization/composer.json",
+                "core/lib/Drupal/Component/Scaffold/composer.json",
                 "core/lib/Drupal/Component/Transliteration/composer.json",
                 "core/lib/Drupal/Component/Utility/composer.json",
                 "core/lib/Drupal/Component/Uuid/composer.json",
diff --git a/core/lib/Drupal/Component/Scaffold/AllowedPackages.php b/core/lib/Drupal/Component/Scaffold/AllowedPackages.php
new file mode 100644
index 0000000000..c589d0f2a0
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/AllowedPackages.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Composer;
+use Composer\IO\IOInterface;
+use Composer\Package\PackageInterface;
+
+/**
+ * Determine recusively which packages have been allowed to scaffold files.
+ *
+ * If the root-level composer.json allows drupal/core, and drupal/core allows
+ * drupal/assets, then the later package will also implicitly be allowed.
+ */
+class AllowedPackages {
+  /**
+   * The Composer service.
+   *
+   * @var \Composer\Composer
+   */
+  protected $composer;
+  protected $io;
+  protected $manageOptions;
+  protected $newPackages;
+
+  /**
+   * ManageOptions constructor.
+   */
+  public function __construct(Composer $composer, IOInterface $io, $manageOptions) {
+    $this->composer = $composer;
+    $this->io = $io;
+    $this->manageOptions = $manageOptions;
+    $this->newPackages = [];
+  }
+
+  /**
+   * Called when a newly-added package is discovered to contian scaffolding instructions.
+   */
+  public function addedPackageWithScaffolding(PackageInterface $package) {
+    $this->newPackages[$package->getName()] = $package;
+  }
+
+  /**
+   * Gets a list of all packages that are allowed to copy scaffold files.
+   *
+   * Configuration for packages specified later will override configuration
+   * specified by packages listed earlier. In other words, the last listed
+   * package has the highest priority. The root package will always be returned
+   * at the end of the list.
+   *
+   * @return \Composer\Package\PackageInterface[]
+   *   An array of allowed Composer packages.
+   */
+  public function getAllowedPackages() {
+    $options = $this->manageOptions->getOptions();
+    $allowed_packages = $this->recursiveGetAllowedPackages($options->allowedPackages());
+    // If the root package defines any file mappings, then implicitly add it
+    // to the list of allowed packages. Add it at the end so that it overrides
+    // all the preceding packages.
+    if ($options->hasFileMapping()) {
+      $root_package = $this->composer->getPackage();
+      unset($allowed_packages[$root_package->getName()]);
+      $allowed_packages[$root_package->getName()] = $root_package;
+    }
+    // Handle any newly-added packages that are not already allowed.
+    $allowed_packages = $this->evaluateNewPackages($allowed_packages);
+    return $allowed_packages;
+  }
+
+  /**
+   * Recursivly build a name-to-package mapping from a list of package names.
+   *
+   * @param string[] $packages_to_allow
+   *   List of package names to allow.
+   * @param array $allowed_packages
+   *   Mapping of package names to PackageInterface of packages already accumulated.
+   *
+   * @return \Composer\Package\PackageInterface[]
+   *   Mapping of package names to PackageInterface in priority order.
+   */
+  protected function recursiveGetAllowedPackages(array $packages_to_allow, array $allowed_packages = []) {
+    foreach ($packages_to_allow as $name) {
+      $package = $this->getPackage($name);
+      if ($package && $package instanceof PackageInterface && !array_key_exists($name, $allowed_packages)) {
+        $allowed_packages[$name] = $package;
+        $packageOptions = $this->manageOptions->packageOptions($package);
+        $allowed_packages = $this->recursiveGetAllowedPackages($packageOptions->allowedPackages(), $allowed_packages);
+      }
+    }
+    return $allowed_packages;
+  }
+
+  /**
+   * Evaluate newly-added packages and see if they are already allowed.
+   *
+   * For now we will only emit warnings if they are not.
+   *
+   * @param array $allowed_packages
+   *   Mapping of package names to PackageInterface of packages already accumulated.
+   *
+   * @return \Composer\Package\PackageInterface[]
+   *   Mapping of package names to PackageInterface in priority order.
+   */
+  protected function evaluateNewPackages(array $allowed_packages) {
+    foreach ($this->newPackages as $name => $newPackage) {
+      if (!array_key_exists($name, $allowed_packages)) {
+        $this->io->write("Package <comment>{$name}</comment> has scaffold operations, but it is not allowed in the root-level composer.json file.");
+      }
+      else {
+        $this->io->write("Package <comment>{$name}</comment> has scaffold operations, and is already allowed in the root-level composer.json file.");
+      }
+    }
+    // In the future, we will allow this method to add more allowed packages.
+    return $allowed_packages;
+  }
+
+  /**
+   * Retrieve a package from the current composer process.
+   *
+   * @param string $name
+   *   Name of the package to get from the current composer installation.
+   *
+   * @return \Composer\Package\PackageInterface|null
+   *   The Composer package.
+   */
+  protected function getPackage($name) {
+    return $this->composer->getRepositoryManager()->getLocalRepository()->findPackage($name, '*');
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/CommandProvider.php b/core/lib/Drupal/Component/Scaffold/CommandProvider.php
new file mode 100644
index 0000000000..9ec63b4a0d
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/CommandProvider.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;
+
+/**
+ * List of all commands provided by this package.
+ */
+class CommandProvider implements CommandProviderCapability {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCommands() {
+    return [new ComposerScaffoldCommand()];
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/ComposerScaffoldCommand.php b/core/lib/Drupal/Component/Scaffold/ComposerScaffoldCommand.php
new file mode 100644
index 0000000000..3e08dd8133
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ComposerScaffoldCommand.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Command\BaseCommand;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * The "composer:scaffold" command class.
+ *
+ * Composer scaffold files and generates the autoload.php file.
+ */
+class ComposerScaffoldCommand extends BaseCommand {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function configure() {
+    parent::configure();
+    $this->setName('composer:scaffold')->setDescription('Update the Composer scaffold files.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(InputInterface $input, OutputInterface $output) {
+    $handler = new Handler($this->getComposer(), $this->getIO());
+    $handler->scaffold();
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/DetectAddingPackagesWithScaffolding.php b/core/lib/Drupal/Component/Scaffold/DetectAddingPackagesWithScaffolding.php
new file mode 100644
index 0000000000..19b670311b
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/DetectAddingPackagesWithScaffolding.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Installer\PackageEvent;
+
+/**
+ * Manage new packages that are added via 'composer require'.
+ *
+ * This package manages examining all required packages, and informing the
+ * allowed package manager whenever a newly-required package is found to
+ * contain scaffolding instructions.
+ */
+class DetectAddingPackagesWithScaffolding {
+  protected $manageAllowedPackages;
+
+  /**
+   * DetectAddingPackagesWithScaffolding constructor.
+   *
+   * @param AllowedPackages $manageAllowedPackages
+   *   The manager that handles allowed packages. We will inform it when new packages are added.
+   */
+  public function __construct(AllowedPackages $manageAllowedPackages) {
+    $this->manageAllowedPackages = $manageAllowedPackages;
+  }
+
+  /**
+   * Handle package events during a 'composer require' operation.
+   *
+   * @param \Composer\Installer\PackageEvent $event
+   *   Composer package event sent on install/update/remove.
+   */
+  public function event(PackageEvent $event) {
+    $operation = $event->getOperation();
+    $jobType = $operation->getJobType();
+    $reason = $operation->getReason();
+    // Get the package.
+    $package = $operation->getJobType() == 'update' ? $operation->getTargetPackage() : $operation->getPackage();
+    if (ScaffoldOptions::hasOptions($package->getExtra())) {
+      $this->manageAllowedPackages->addedPackageWithScaffolding($package);
+    }
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/GenerateAutoloadReferenceFile.php b/core/lib/Drupal/Component/Scaffold/GenerateAutoloadReferenceFile.php
new file mode 100644
index 0000000000..5a042467af
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/GenerateAutoloadReferenceFile.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
+use Drupal\Component\Scaffold\Operations\ScaffoldResult;
+
+/**
+ * Generates an 'autoload.php' that includes the autoloader created by Composer.
+ */
+class GenerateAutoloadReferenceFile {
+  protected $vendorPath;
+
+  /**
+   * GenerateAutoloadReferenceFile constructor.
+   *
+   * @param string $vendorPath
+   *   Path to the vendor directory.
+   */
+  public function __construct($vendorPath) {
+    $this->vendorPath = $vendorPath;
+  }
+
+  /**
+   * Generate the autoload file at the specified location.
+   *
+   * This only writes a bit of PHP that includes the autoload file that
+   * Composer generated. Drupal does this so that it can guarentee that there
+   * will always be an `autoload.php` file in a well-known location.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $autoloadPath
+   *   Where to write the autoload file.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
+   *   The result of the autoload file generation
+   */
+  public function generateAutoload(ScaffoldFilePath $autoloadPath) {
+    $location = dirname($autoloadPath->fullPath());
+    // Calculate the relative path from the webroot (location of the project
+    // autoload.php) to the vendor directory.
+    $fs = new SymfonyFilesystem();
+    $relativeVendorPath = $fs->makePathRelative($this->vendorPath, realpath($location));
+    $fs->dumpFile($autoloadPath->fullPath(), $this->autoLoadContents($relativeVendorPath));
+    return (new ScaffoldResult($autoloadPath))->setManaged();
+  }
+
+  /**
+   * Build the contents of the autoload file.
+   *
+   * @return string
+   *   Return the contents for the autoload.php.
+   */
+  protected function autoLoadContents($relativeVendorPath) {
+    $relativeVendorPath = rtrim($relativeVendorPath, '/');
+    return <<<EOF
+<?php
+
+/**
+ * @file
+ * Includes the autoloader created by Composer.
+ *
+ * This file was generated by composer-scaffold.
+ *.
+ * @see composer.json
+ * @see index.php
+ * @see core/install.php
+ * @see core/rebuild.php
+ * @see core/modules/statistics/statistics.php
+ */
+
+return require __DIR__ . '/{$relativeVendorPath}/autoload.php';
+
+EOF;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Handler.php b/core/lib/Drupal/Component/Scaffold/Handler.php
new file mode 100644
index 0000000000..4a6e30ec99
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Handler.php
@@ -0,0 +1,231 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Composer;
+use Composer\EventDispatcher\EventDispatcher;
+use Composer\Installer\PackageEvent;
+use Composer\IO\IOInterface;
+use Composer\Package\PackageInterface;
+use Composer\Plugin\CommandEvent;
+use Composer\Script\Event;
+use Composer\Util\Filesystem;
+use Drupal\Component\Scaffold\Operations\OperationCollection;
+use Drupal\Component\Scaffold\Operations\OperationFactory;
+
+/**
+ * Core class of the plugin.
+ *
+ * Contains the primary logic which determines the files to be fetched and processed.
+ */
+class Handler {
+  const PRE_COMPOSER_SCAFFOLD_CMD = 'pre-composer-scaffold-cmd';
+  const POST_COMPOSER_SCAFFOLD_CMD = 'post-composer-scaffold-cmd';
+  /**
+   * The Composer service.
+   *
+   * @var \Composer\Composer
+   */
+  protected $composer;
+  /**
+   * Composer's I/O service.
+   *
+   * @var \Composer\IO\IOInterface
+   */
+  protected $io;
+  protected $manageOptions;
+  protected $manageAllowedPackages;
+  protected $postPackageListeners;
+
+  /**
+   * Handler constructor.
+   *
+   * @param \Composer\Composer $composer
+   *   The Composer service.
+   * @param \Composer\IO\IOInterface $io
+   *   The Composer I/O service.
+   */
+  public function __construct(Composer $composer, IOInterface $io) {
+    $this->composer = $composer;
+    $this->io = $io;
+    $this->manageOptions = new ManageOptions($composer);
+    $this->manageAllowedPackages = new AllowedPackages($composer, $io, $this->manageOptions);
+    $this->postPackageListeners = [];
+  }
+
+  /**
+   * Post install command event to execute the scaffolding.
+   *
+   * @param \Composer\Script\Event $event
+   *   The Composer event.
+   */
+  public function onPostCmdEvent(Event $event) {
+    $this->scaffold();
+  }
+
+  /**
+   * The beforeRequire method is called before any 'require' event runs.
+   *
+   * @param \Composer\Plugin\CommandEvent $event
+   *   The Composer Command event.
+   */
+  public function beforeRequire(CommandEvent $event) {
+    // In order to differentiate between post-package events called after
+    // 'composer require' vs. the same events called at other times, we will
+    // only install our handler when a 'require' event is detected.
+    $this->postPackageListeners[] = new DetectAddingPackagesWithScaffolding($this->manageAllowedPackages);
+  }
+
+  /**
+   * Post package command event.
+   *
+   * We want to detect packages 'require'd that have scaffold files, but are
+   * not yet allowed in the top-level composer.json file.
+   *
+   * @param \Composer\Installer\PackageEvent $event
+   *   Composer package event sent on install/update/remove.
+   */
+  public function onPostPackageEvent(PackageEvent $event) {
+    foreach ($this->postPackageListeners as $listener) {
+      $listener->event($event);
+    }
+  }
+
+  /**
+   * Gets the array of file mappings provided by a given package.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The Composer package from which to get the file mappings.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
+   *   An array of destination paths => scaffold operation objects.
+   */
+  public function getPackageFileMappings(PackageInterface $package) {
+    $options = $this->manageOptions->packageOptions($package);
+    if ($options->hasFileMapping()) {
+      return $this->createScaffoldOperations($package, $options->fileMapping());
+    }
+    else {
+      if (!$options->hasAllowedPackages()) {
+        $this->io->writeError("The allowed package {$package->getName()} does not provide a file mapping for Composer Scaffold.");
+      }
+      return [];
+    }
+  }
+
+  /**
+   * Create scaffold operation objects for all items in the file mappings.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package that relative paths will be relative from.
+   * @param array $package_file_mappings
+   *   The package file mappings array (destination path => operation metadata array)
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
+   *   A list of scaffolding operation objects
+   */
+  protected function createScaffoldOperations(PackageInterface $package, array $package_file_mappings) {
+    $options = $this->manageOptions->getOptions();
+    $scaffoldOpFactory = new OperationFactory($this->composer);
+    $scaffoldOps = [];
+    foreach ($package_file_mappings as $key => $value) {
+      $metadata = $scaffoldOpFactory->normalizeScaffoldMetadata($key, $value);
+      $scaffoldOps[$key] = $scaffoldOpFactory->createScaffoldOp($package, $key, $metadata, $options);
+    }
+    return $scaffoldOps;
+  }
+
+  /**
+   * Copies all scaffold files from source to destination.
+   */
+  public function scaffold() {
+    // Recursively get the list of allowed packages. Only allowed packages
+    // may declare scaffold files. Note that the top-level composer.json file
+    // is implicitly allowed.
+    $allowedPackages = $this->manageAllowedPackages->getAllowedPackages();
+    if (empty($allowedPackages)) {
+      return;
+    }
+    // Call any pre-scaffold scripts that may be defined.
+    $dispatcher = new EventDispatcher($this->composer, $this->io);
+    $dispatcher->dispatch(self::PRE_COMPOSER_SCAFFOLD_CMD);
+    // Fetch the list of file mappings from each allowed package and
+    // normalize them.
+    $file_mappings = $this->getFileMappingsFromPackages($allowedPackages);
+    // Analyze the list of file mappings, and determine which take priority.
+    $scaffoldCollection = new OperationCollection($this->io);
+    $locationReplacements = $this->manageOptions->getLocationReplacements();
+    $scaffoldCollection->coalateScaffoldFiles($file_mappings, $locationReplacements);
+    // Write the collected scaffold files to the designated location on disk.
+    $scaffoldResults = $scaffoldCollection->processScaffoldFiles($this->manageOptions->getOptions());
+    // Generate an autoload file in the document root that includes
+    // the autoload.php file in the vendor directory, wherever that is.
+    // Drupal requires this in order to easily locate relocated vendor dirs.
+    $autoloadPath = ScaffoldFilePath::autoloadPath($this->rootPackageName(), $this->getWebRoot());
+    $generator = new GenerateAutoloadReferenceFile($this->getVendorPath());
+    $scaffoldResults[] = $generator->generateAutoload($autoloadPath);
+    // Add the managed scaffold files to .gitignore if applicable.
+    $manager = new ManageGitIgnore(getcwd());
+    $manager->manageIgnored($scaffoldResults, $this->manageOptions->getOptions());
+    // Call post-scaffold scripts.
+    $dispatcher->dispatch(self::POST_COMPOSER_SCAFFOLD_CMD);
+  }
+
+  /**
+   * Retrieve the path to the web root.
+   *
+   * Note that only the root package can define the web root.
+   *
+   * @return string
+   *   The file path of the web root.
+   *
+   * @throws \Exception
+   */
+  public function getWebRoot() {
+    return $this->manageOptions->getOptions()->requiredLocation('web-root', "The extra.composer-scaffold.location.web-root is not set in composer.json.");
+  }
+
+  /**
+   * Get the path to the 'vendor' directory.
+   *
+   * @return string
+   *   The file path of the vendor directory.
+   */
+  public function getVendorPath() {
+    $vendorDir = $this->composer->getConfig()->get('vendor-dir');
+    $filesystem = new Filesystem();
+    $filesystem->ensureDirectoryExists($vendorDir);
+    return $filesystem->normalizePath(realpath($vendorDir));
+  }
+
+  /**
+   * Gets a consolidated list of file mappings from all allowed packages.
+   *
+   * @param \Composer\Package\Package[] $allowed_packages
+   *   A multidimensional array of file mappings, as returned by
+   *   self::getAllowedPackages().
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
+   *   An array of destination paths => scaffold operation objects.
+   */
+  protected function getFileMappingsFromPackages(array $allowed_packages) {
+    $file_mappings = [];
+    foreach ($allowed_packages as $package_name => $package) {
+      $package_file_mappings = $this->getPackageFileMappings($package);
+      $file_mappings[$package_name] = $package_file_mappings;
+    }
+    return $file_mappings;
+  }
+
+  /**
+   * Get the root package name.
+   *
+   * @return string
+   *   The package name of the root project
+   */
+  protected function rootPackageName() {
+    $root_package = $this->composer->getPackage();
+    return $root_package->getName();
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Interpolator.php b/core/lib/Drupal/Component/Scaffold/Interpolator.php
new file mode 100644
index 0000000000..352b4229f3
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Interpolator.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+/**
+ * Inject config values from an associative array into a string.
+ */
+class Interpolator {
+  protected $startToken;
+  protected $endToken;
+  protected $data;
+
+  /**
+   * Interpolator constructor.
+   *
+   * @param string $startToken
+   *   The start marker for a token, e.g. '['.
+   * @param string $endToken
+   *   The end marker for a token, e.g. ']'.
+   */
+  public function __construct($startToken = '\\[', $endToken = '\\]') {
+    $this->startToken = $startToken;
+    $this->endToken = $endToken;
+    $this->data = [];
+  }
+
+  /**
+   * SetData allows the client to associate a standard data set to use when interpolating.
+   *
+   * @param array $data
+   *   Interpolation data to use when interpolating.
+   */
+  public function setData(array $data) {
+    $this->data = $data;
+    return $this;
+  }
+
+  /**
+   * Add data allows the client to add to the standard data set to use when interpolating.
+   *
+   * @param array $data
+   *   Interpolation data to use when interpolating.
+   */
+  public function addData(array $data) {
+    $this->data = array_merge($this->data, $data);
+    return $this;
+  }
+
+  /**
+   * Interpolate replaces tokens in a string with values from an associative array.
+   *
+   * Tokens are surrounded by double curley braces, e.g. "[key]". The characters
+   * that surround the key may be defined when the Interpolator is constructed.
+   *
+   * Example:
+   * If the message is 'Hello, [user.name]', then the value of the user.name
+   * item is fetched from the array, and the token [user.name] is
+   * replaced with the result.
+   *
+   * @param string $message
+   *   Message containing tokens to be replaced.
+   * @param mixed|array $extra
+   *   Data to use for interpolation in addition to whatever was provided by self::setData().
+   * @param string|bool $default
+   *   The value to substitute for tokens that
+   *   are not found in the data. If `false`, then missing
+   *   tokens are not replaced.
+   *
+   * @return string
+   *   The message after replacements have been made.
+   */
+  public function interpolate($message, $extra = [], $default = '') {
+    $data = $extra + $this->data;
+    $replacements = $this->replacements($message, $data, $default);
+    return strtr($message, $replacements);
+  }
+
+  /**
+   * FindTokens finds all of the tokens in the provided message.
+   *
+   * @param string $message
+   *   String with tokens.
+   *
+   * @return string[]
+   *   map of token to key, e.g. {{key}} => key
+   */
+  public function findTokens($message) {
+    $regEx = '#' . $this->startToken . '([a-zA-Z0-9._-]+)' . $this->endToken . '#';
+    if (!preg_match_all($regEx, $message, $matches, PREG_SET_ORDER)) {
+      return [];
+    }
+    $tokens = [];
+    foreach ($matches as $matchSet) {
+      list($sourceText, $key) = $matchSet;
+      $tokens[$sourceText] = $key;
+    }
+    return $tokens;
+  }
+
+  /**
+   * Replacements finds the tokens that exist in a message and builds a replacement array.
+   *
+   * All of the replacements in the data array are looked up given the token
+   * keys from the provided message. Keys that do not exist in the configuration
+   * are replaced with the default value.
+   */
+  protected function replacements($message, $data, $default = '') {
+    $tokens = $this->findTokens($message);
+    $replacements = [];
+    foreach ($tokens as $sourceText => $key) {
+      $replacementText = $this->get($key, $data, $default);
+      if ($replacementText !== FALSE) {
+        $replacements[$sourceText] = $replacementText;
+      }
+    }
+    return $replacements;
+  }
+
+  /**
+   * Get a value from an array.
+   */
+  protected function get($key, $data, $default) {
+    return array_key_exists($key, $data) ? $data[$key] : $default;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/LICENSE.txt b/core/lib/Drupal/Component/Scaffold/LICENSE.txt
new file mode 100644
index 0000000000..94fb84639c
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/LICENSE.txt
@@ -0,0 +1,339 @@
+        GNU GENERAL PUBLIC LICENSE
+           Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+          Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+        GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+          NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+         END OF TERMS AND CONDITIONS
+
+      How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, write to the Free Software Foundation, Inc.,
+    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Ty Coon>, 1 April 1989
+  Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/core/lib/Drupal/Component/Scaffold/ManageGitIgnore.php b/core/lib/Drupal/Component/Scaffold/ManageGitIgnore.php
new file mode 100644
index 0000000000..917db7540a
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ManageGitIgnore.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Symfony\Component\Process\Process;
+
+/**
+ * Manage the .gitignore file.
+ */
+class ManageGitIgnore {
+  protected $dir;
+
+  /**
+   * ManageGitIgnore constructor.
+   *
+   * @param string $dir
+   *   The directory where the project is located.
+   */
+  public function __construct($dir) {
+    $this->dir = $dir;
+  }
+
+  /**
+   * Determine whether the specified scaffold file is already ignored.
+   *
+   * @param string $path
+   *   Path to scaffold file to check.
+   *
+   * @return bool
+   *   Whether the specified file is already ignored or not (TRUE if ignored).
+   */
+  public function checkIgnore($path) {
+    $process = new Process('git check-ignore ' . $path, $this->dir);
+    $process->run();
+    $isIgnored = $process->getExitCode() == 0;
+    return $isIgnored;
+  }
+
+  /**
+   * Determine whether the specified scaffold file is tracked in the repository.
+   *
+   * @param string $path
+   *   Path to scaffold file to check.
+   *
+   * @return bool
+   *   Whether the specified file is already tracked or not (TRUE if tracked).
+   */
+  public function checkTracked($path) {
+    $process = new Process('git ls-files --error-unmatch ' . $path, $this->dir);
+    $process->run();
+    $isTracked = $process->getExitCode() == 0;
+    return $isTracked;
+  }
+
+  /**
+   * Check to see if the project root dir is in a git repository.
+   *
+   * @return bool
+   *   True if this is a repository.
+   */
+  public function isRepository() {
+    $process = new Process('git rev-parse --show-toplevel', $this->dir);
+    $process->run();
+    $isRepository = $process->getExitCode() == 0;
+    return $isRepository;
+  }
+
+  /**
+   * Check to see if the vendor directory is git ignored.
+   *
+   * @return bool
+   *   True if 'vendor' is committed, or false if it is ignored.
+   */
+  public function vendorCommitted() {
+    return $this->checkTracked('vendor');
+  }
+
+  /**
+   * Determine whether we should manage gitignore files.
+   *
+   * @param ScaffoldOptions $options
+   *   Configuration options from the composer.json extras section.
+   *
+   * @return bool
+   *   Whether or not gitignore files should be managed.
+   */
+  public function managementOfGitIgnoreEnabled(ScaffoldOptions $options) {
+    // If the composer.json stipulates whether gitignore is managed or not,
+    // then follow its recommendation.
+    if ($options->hasGitIgnore()) {
+      return $options->gitIgnore();
+    }
+    // Do not manage .gitignore if there is no repository here.
+    if (!$this->isRepository()) {
+      return FALSE;
+    }
+    // If the composer.json did not specify whether or not gitignore files should
+    // be managed, then manage them if the vendor directory is not committed.
+    return !$this->vendorCommitted();
+  }
+
+  /**
+   * Manage gitignore files.
+   *
+   * @param array $files
+   *   A list of scaffold results, each of which holds a path and whether
+   *   or not that file is managed.
+   * @param ScaffoldOptions $options
+   *   Configuration options from the composer.json extras section.
+   */
+  public function manageIgnored(array $files, ScaffoldOptions $options) {
+    if (!$this->managementOfGitIgnoreEnabled($options)) {
+      return;
+    }
+    // Accumulate entried to add to .gitignore, sorted into buckets based
+    // on the location of the .gitignore file the entry should be added to.
+    $addToGitIgnore = [];
+    foreach ($files as $scaffoldResult) {
+      $isIgnored = $this->checkIgnore($scaffoldResult->destination()->fullPath());
+      $isTracked = $this->checkTracked($scaffoldResult->destination()->fullPath());
+      if (!$isIgnored && !$isTracked && $scaffoldResult->isManaged()) {
+        $path = $scaffoldResult->destination()->fullPath();
+        $dir = realpath(dirname($path));
+        $name = basename($path);
+        $addToGitIgnore[$dir][] = $name;
+      }
+    }
+    // Write out the .gitignore files one at a time.
+    foreach ($addToGitIgnore as $dir => $entries) {
+      $this->addToGitIgnore($dir, $entries);
+    }
+  }
+
+  /**
+   * Add a set of entries to the specified .gitignore file.
+   *
+   * @param string $dir
+   *   Path to directory where gitignore should be written.
+   * @param string[] $entries
+   *   Entries to write to .gitignore file.
+   */
+  public function addToGitIgnore($dir, array $entries) {
+    sort($entries);
+    $gitIgnorePath = $dir . '/.gitignore';
+    $contents = $this->gitIgnoreContents($gitIgnorePath);
+    $contents .= implode("\n", $entries);
+    file_put_contents($gitIgnorePath, $contents);
+  }
+
+  /**
+   * Fetch the current contents of the specified .gitignore file.
+   *
+   * @param string $gitIgnorePath
+   *   Path to .gitignore file.
+   *
+   * @return string
+   *   Contents of .gitignore. Will always end with a "\n" unless empty.
+   */
+  public function gitIgnoreContents($gitIgnorePath) {
+    if (!file_exists($gitIgnorePath)) {
+      return '';
+    }
+    $contents = file_get_contents($gitIgnorePath);
+    if (!empty($contents) && substr($contents, -1) != "\n") {
+      $contents .= "\n";
+    }
+    return $contents;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/ManageOptions.php b/core/lib/Drupal/Component/Scaffold/ManageOptions.php
new file mode 100644
index 0000000000..6c430a09ed
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ManageOptions.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Composer;
+use Composer\Package\PackageInterface;
+use Composer\Util\Filesystem;
+
+/**
+ * Per-project options from the 'extras' section of the composer.json file.
+ *
+ * Projects that describe scaffold files do so via their scaffold options.
+ * This data is pulled from the 'composer-scaffold' portion of the extras
+ * section of the project data.
+ */
+class ManageOptions {
+  /**
+   * The Composer service.
+   *
+   * @var \Composer\Composer
+   */
+  protected $composer;
+
+  /**
+   * ManageOptions constructor.
+   */
+  public function __construct($composer) {
+    $this->composer = $composer;
+  }
+
+  /**
+   * Get the root-level scaffold options for this project.
+   *
+   * @return ScaffoldOptions
+   *   The scaffold otpions object
+   */
+  public function getOptions() {
+    return $this->packageOptions($this->composer->getPackage());
+  }
+
+  /**
+   * The scaffold options for the stipulated project.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package to fetch the scaffold options from.
+   *
+   * @return ScaffoldOptions
+   *   The scaffold otpions object
+   */
+  public function packageOptions(PackageInterface $package) {
+    return ScaffoldOptions::create($package->getExtra());
+  }
+
+  /**
+   * GetLocationReplacements creates an interpolator for the 'locations' element.
+   *
+   * The interpolator returned will replace a path string with the tokens
+   * defined in the 'locations' element.
+   *
+   * Note that only the root package may define locations.
+   *
+   * @return Interpolator
+   *   Object that will do replacements in a string using tokens in 'locations' element.
+   */
+  public function getLocationReplacements() {
+    return (new Interpolator())->setData($this->ensureLocations());
+  }
+
+  /**
+   * Ensure that all of the locatons defined in the scaffold filed exist.
+   *
+   * Create them on the filesystem if they do not.
+   */
+  public function ensureLocations() {
+    $fs = new Filesystem();
+    $locations = $this->getOptions()->locations() + ['web_root' => './'];
+    $locations = array_map(function ($location) use ($fs) {
+      $fs->ensureDirectoryExists($location);
+      $location = realpath($location);
+      return $location;
+    }, $locations);
+    return $locations;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/AppendOp.php b/core/lib/Drupal/Component/Scaffold/Operations/AppendOp.php
new file mode 100644
index 0000000000..69c98a3189
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/AppendOp.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\Interpolator;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * Scaffold operation to add to the beginning and/or end of a scaffold file.
+ */
+class AppendOp implements OperationInterface, OriginalOpAwareInterface {
+  use OriginalOpAwareTrait;
+  protected $prepend;
+  protected $append;
+
+  /**
+   * Set the relative path to the prepend file.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $prependPath
+   *   The relative path to the prepend file file.
+   *
+   * @return $this
+   */
+  public function setPrependFile(ScaffoldFilePath $prependPath) {
+    $this->prepend = $prependPath;
+    return $this;
+  }
+
+  /**
+   * Set the relative path to the append file.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $appendPath
+   *   The relative path to the append file file.
+   *
+   * @return $this
+   */
+  public function setAppendFile(ScaffoldFilePath $appendPath) {
+    $this->append = $appendPath;
+    return $this;
+  }
+
+  /**
+   * Add interpolation data for our append and prepend source files.
+   *
+   * @param \Drupal\Component\Scaffold\Interpolator $interpolator
+   *   Interpolator to add data to.
+   */
+  protected function addInterpolationData(Interpolator $interpolator) {
+    if (isset($this->prepend)) {
+      $this->prepend->addInterpolationData($interpolator, 'prepend');
+    }
+    if (isset($this->append)) {
+      $this->append->addInterpolationData($interpolator, 'append');
+    }
+  }
+
+  /**
+   * Append or prepend information onto the overridden scaffold file.
+   *
+   * {@inheritdoc}
+   */
+  public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+    $interpolator = $destination->getInterpolator();
+    $this->addInterpolationData($interpolator);
+    $destination_path = $destination->fullPath();
+    // It is not possible to append / prepend unless the destination path
+    // is the same as some scaffold file provided by an earlier package.
+    if (!$this->hasOriginalOp()) {
+      throw new \Exception($interpolator->interpolate("Cannot append/prepend because no prior package provided a scaffold file at that [dest-rel-path]."));
+    }
+    // First, scaffold the original file. Disable symlinking, because we
+    // need a copy of the file if we're going to append / prepend to it.
+    @unlink($destination_path);
+    $this->originalOp()->process($destination, $io, $options->overrideSymlink(FALSE));
+    // Fetch the prepend contents, if provided.
+    $prependContents = '';
+    if (!empty($this->prepend)) {
+      $prependContents = file_get_contents($this->prepend->fullPath()) . "\n";
+      $io->write($interpolator->interpolate("  - Prepend to <info>[dest-rel-path]</info> from <info>[prepend-rel-path]</info>"));
+    }
+    // Fetch the append contents, if provided.
+    $appendContents = '';
+    if (!empty($this->append)) {
+      $appendContents = "\n" . file_get_contents($this->append->fullPath());
+      $io->write($interpolator->interpolate("  - Append to <info>[dest-rel-path]</info> from <info>[append-rel-path]</info>"));
+    }
+    $this->append($destination, $prependContents, $appendContents);
+    return (new ScaffoldResult($destination))->setManaged();
+  }
+
+  /**
+   * Do the actuall append / prepend operation for the provided scaffold file.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+   *   The scaffold file to append / prepend to.
+   * @param string $prependContents
+   *   The contents to add to the beginning of the file.
+   * @param string $appendContents
+   *   The contents to add to the end of the file.
+   */
+  protected function append(ScaffoldFilePath $destination, $prependContents, $appendContents) {
+    $interpolator = $destination->getInterpolator();
+    $destination_path = $destination->fullPath();
+    // Exit early if there is no append / prepend data.
+    if (empty(trim($prependContents)) && empty(trim($appendContents))) {
+      $io->write($interpolator->interpolate("  - Keep <info>[dest-rel-path]</info> unchanged: no content to prepend / append was provided."));
+      return;
+    }
+    // We're going to assume that none of these files are going to be
+    // very large, so we will just load them all into memory for now.
+    // We'd want to use streaminig if we thought that anyone would scaffold
+    // and append very large files.
+    $originalContents = file_get_contents($destination_path);
+    // Write the appended / prepended contents back to the file.
+    $alteredContents = $prependContents . $originalContents . $appendContents;
+    file_put_contents($destination_path, $alteredContents);
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OperationCollection.php b/core/lib/Drupal/Component/Scaffold/Operations/OperationCollection.php
new file mode 100644
index 0000000000..c2ea133f5e
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OperationCollection.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\Interpolator;
+use Drupal\Component\Scaffold\ScaffoldFileInfo;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * OperationCollection keeps track of the collection of files to be scaffolded.
+ */
+class OperationCollection {
+  protected $listOfScaffoldFiles;
+  protected $resolvedFileMappings;
+  protected $io;
+
+  /**
+   * OperationCollection constructor.
+   *
+   * @param \Composer\IO\IOInterface $io
+   *   A reference to the IO object, to allow us to write progress messages
+   *   as we process scaffold operations.
+   */
+  public function __construct(IOInterface $io) {
+    $this->io = $io;
+  }
+
+  /**
+   * Fetch the file mappings.
+   *
+   * @return array
+   *   Associative array containing package name => file mappings
+   */
+  public function fileMappings() {
+    return $this->resolvedFileMappings;
+  }
+
+  /**
+   * Get the final list of what should be scaffolded where.
+   *
+   * @return array
+   *   Associative array containing destination => operation mappings
+   */
+  public function scaffoldList() {
+    return $this->listOfScaffoldFiles;
+  }
+
+  /**
+   * Return the package name that provides the scaffold file info at this destination path.
+   *
+   * Given the list of all scaffold file info objects, return the package that
+   * provides the scaffold file info for the scaffold file that will be placed
+   * at the destination that this scaffold file would be placed at. Note that
+   * this will be the same as $scaffold_file->packageName() unless this scaffold
+   * file has been overridden or removed by some other package.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFileInfo $scaffold_file
+   *   The scaffold file to use to find a providing package name.
+   *
+   * @return string
+   *   The name of the package that provided the scaffold file information.
+   */
+  public function findProvidingPackage(ScaffoldFileInfo $scaffold_file) {
+    // The scaffold file should always be in our list, but we will check
+    // just to be sure that it really is.
+    $scaffoldList = $this->scaffoldList();
+    $dest_rel_path = $scaffold_file->destination()->relativePath();
+    if (!array_key_exists($dest_rel_path, $scaffoldList)) {
+      throw new \Exception("Scaffold file not found in list of all scaffold files.");
+    }
+    $overridden_scaffold_file = $scaffoldList[$dest_rel_path];
+    return $overridden_scaffold_file->packageName();
+  }
+
+  /**
+   * Copy all files, as defined by $file_mappings.
+   *
+   * @param array $file_mappings
+   *   An multidimensional array of file mappings, as returned by
+   *   self::getFileMappingsFromPackages().
+   * @param \Drupal\Component\Scaffold\Interpolator $locationReplacements
+   *   An object with the location mappings (e.g. [web-root]).
+   */
+  public function coalateScaffoldFiles(array $file_mappings, Interpolator $locationReplacements) {
+    $resolved_file_mappings = [];
+    $resolved_package_file_list = [];
+    $list_of_scaffold_files = [];
+    foreach ($file_mappings as $package_name => $package_file_mappings) {
+      foreach ($package_file_mappings as $destination_rel_path => $op) {
+        $destination = ScaffoldFilePath::destinationPath($package_name, $destination_rel_path, $locationReplacements);
+        $scaffold_file = (new ScaffoldFileInfo())->setDestination($destination)->setOp($op);
+        // If there was already a scaffolding operation happening at this
+        // path, then pass it along to the new scaffold op, if it cares.
+        if (isset($list_of_scaffold_files[$destination_rel_path]) && $op instanceof OriginalOpAwareInterface) {
+          $op->setOriginalOp($list_of_scaffold_files[$destination_rel_path]->op());
+        }
+        $list_of_scaffold_files[$destination_rel_path] = $scaffold_file;
+        $resolved_file_mappings[$package_name][$destination_rel_path] = $scaffold_file;
+      }
+    }
+    $this->listOfScaffoldFiles = $list_of_scaffold_files;
+    $this->resolvedFileMappings = $resolved_file_mappings;
+  }
+
+  /**
+   * Scaffolds the files in our scaffold collection, package-by-package.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Configuration options from the top-level composer.json file.
+   *
+   * @return ScaffoldResult[]
+   *   Associative array of destination path : scaffold result for each scaffolded file.
+   */
+  public function processScaffoldFiles(ScaffoldOptions $options) {
+    $result = [];
+    // We could simply scaffold all of the files from $list_of_scaffold_files,
+    // which contain only the list of files to be processed. We iterate over
+    // $resolved_file_mappings instead so that we can print out all of the
+    // scaffold files grouped by the package that provided them, including
+    // those not being scaffolded (because they were overridden or removed
+    // by some later package).
+    foreach ($this->fileMappings() as $package_name => $package_scaffold_files) {
+      $this->io->write("Scaffolding files for <comment>{$package_name}</comment>:");
+      foreach ($package_scaffold_files as $dest_rel_path => $scaffold_file) {
+        $overriding_package = $this->findProvidingPackage($scaffold_file);
+        if ($scaffold_file->overridden($overriding_package)) {
+          $this->io->write($scaffold_file->interpolate("  - Skip <info>[dest-rel-path]</info>: overridden in <comment>{$overriding_package}</comment>"));
+        }
+        else {
+          $result[$dest_rel_path] = $scaffold_file->process($this->io, $options);
+        }
+      }
+    }
+    return $result;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OperationFactory.php b/core/lib/Drupal/Component/Scaffold/Operations/OperationFactory.php
new file mode 100644
index 0000000000..d23cae707c
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OperationFactory.php
@@ -0,0 +1,183 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\Composer;
+use Composer\Package\PackageInterface;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * Create Scaffold operation objects based on provided metadata.
+ */
+class OperationFactory {
+  protected $composer;
+
+  /**
+   * OperationFactory constructor.
+   *
+   * @param \Composer\Composer $composer
+   *   Reference to the 'Composer' object, since the Scaffold Operation Factory
+   *   is also responsible for evaluating relative package paths as it creates
+   *   scaffold operations.
+   */
+  public function __construct(Composer $composer) {
+    $this->composer = $composer;
+  }
+
+  /**
+   * Normalize metadata, converting literal values into arrays with the same meaning.
+   *
+   * Conversions performed include:
+   *   - Boolean 'false' means "skip".
+   *   - A string menas "replace", with the string value becoming the path.
+   *
+   * @param string $key
+   *   The key (destination path) for the value to normalize.
+   * @param mixed $value
+   *   The metadata for this operation object, which varies by operation type.
+   *
+   * @return array
+   *   Normalized scaffold metadata.
+   */
+  public function normalizeScaffoldMetadata($key, $value) {
+    if (is_bool($value)) {
+      if (!$value) {
+        return ['mode' => 'skip'];
+      }
+      throw new \Exception("File mapping {$key} cannot be given the value 'true'.");
+    }
+    if (empty($value)) {
+      throw new \Exception("File mapping {$key} cannot be empty.");
+    }
+    if (is_string($value)) {
+      $value = ['path' => $value];
+    }
+    // If there is no 'mode', but there is an 'append' or a 'prepend' path,
+    // then the mode is 'append' (append + prepend).
+    if (!isset($value['mode']) && (isset($value['append']) || isset($value['prepend']))) {
+      $value['mode'] = 'append';
+    }
+    // If there is no 'mode', then the default is 'replace'.
+    if (!isset($value['mode'])) {
+      $value['mode'] = 'replace';
+    }
+    return $value;
+  }
+
+  /**
+   * Create a scaffolding operation object of an appropriate for the provided metadata.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package that relative paths will be relative from.
+   * @param string $dest_rel_path
+   *   The destination path for the scaffold file. Used only for error messages.
+   * @param mixed $value
+   *   The metadata for this operation object, which varies by operation type.
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Configuration options from the top-level composer.json file.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+   *   The scaffolding operation object (skip, replace, etc.)
+   */
+  public function createScaffoldOp(PackageInterface $package, $dest_rel_path, $value, ScaffoldOptions $options) {
+    switch ($value['mode']) {
+      case 'skip':
+        return new SkipOp();
+
+      case 'replace':
+        return $this->createReplaceOp($package, $dest_rel_path, $value, $options);
+
+      case 'append':
+        return $this->createAppendOp($package, $dest_rel_path, $value, $options);
+    }
+    throw new \Exception("Unknown scaffold opperation mode <comment>{$value['mode']}</comment>.");
+  }
+
+  /**
+   * Create a 'replace' scaffold op.
+   *
+   * Replace ops may copy or symlink, depending on settings.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package that relative paths will be relative from.
+   * @param string $dest_rel_path
+   *   The destination path for the scaffold file. Used only for error messages.
+   * @param array $metadata
+   *   The metadata for this operation object, i.e. the relative 'path'.
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Configuration options from the top-level composer.json file.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+   *   A scaffold replace operation obejct.
+   */
+  protected function createReplaceOp(PackageInterface $package, $dest_rel_path, array $metadata, ScaffoldOptions $options) {
+    $op = new ReplaceOp();
+    // If this op does not provide an 'overwrite' value, default it to true.
+    $metadata += ['overwrite' => TRUE];
+    if (!isset($metadata['path'])) {
+      throw new \Exception("'path' component required for 'replace' operations.");
+    }
+    $package_name = $package->getName();
+    $package_path = $this->getPackagePath($package);
+    $source = ScaffoldFilePath::sourcePath($package_name, $package_path, $dest_rel_path, $metadata['path']);
+    $op->setSource($source)->setOverwrite($metadata['overwrite']);
+    return $op;
+  }
+
+  /**
+   * Create an 'append' (or 'prepend') scaffold op.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package that relative paths will be relative from.
+   * @param string $dest_rel_path
+   *   The destination path for the scaffold file. Used only for error messages.
+   * @param array $metadata
+   *   The metadata for this operation object, i.e. the relative 'path'.
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Configuration options from the top-level composer.json file.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+   *   A scaffold replace operation obejct.
+   */
+  protected function createAppendOp(PackageInterface $package, $dest_rel_path, array $metadata, ScaffoldOptions $options) {
+    $op = new AppendOp();
+    $package_name = $package->getName();
+    $package_path = $this->getPackagePath($package);
+    if (isset($metadata['prepend'])) {
+      $prepend_source_file = ScaffoldFilePath::sourcePath($package_name, $package_path, $dest_rel_path, $metadata['prepend']);
+      $op->setPrependFile($prepend_source_file);
+    }
+    if (isset($metadata['append'])) {
+      $append_source_file = ScaffoldFilePath::sourcePath($package_name, $package_path, $dest_rel_path, $metadata['append']);
+      $op->setAppendFile($append_source_file);
+    }
+    return $op;
+  }
+
+  /**
+   * Gets the file path of a package.
+   *
+   * Note that if we call getInstallPath on the root package, we get the
+   * wrong answer (the installation manager thinks our package is in
+   * vendor). We therefore add special checking for this case.
+   *
+   * @param \Composer\Package\PackageInterface $package
+   *   The package.
+   *
+   * @return string
+   *   The file path.
+   */
+  protected function getPackagePath(PackageInterface $package) {
+    if ($package->getName() == $this->composer->getPackage()->getName()) {
+      // This will respect the --working-dir option if Composer is invoked with
+      // it. There is no API or method to determine the filesystem path of
+      // a package's composer.json file.
+      return getcwd();
+    }
+    else {
+      return $this->composer->getInstallationManager()->getInstallPath($package);
+    }
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OperationInterface.php b/core/lib/Drupal/Component/Scaffold/Operations/OperationInterface.php
new file mode 100644
index 0000000000..a9526b0cb3
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OperationInterface.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * Data file that keeps track of one scaffold file's source, destination, and package.
+ */
+interface OperationInterface {
+
+  /**
+   * Process this scaffold operation.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+   *   Scaffold file's destination path.
+   * @param \Composer\IO\IOInterface $io
+   *   IOInterface to writing to.
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Various options that may alter the behavior of the operation.
+   *
+   * @return ScaffoldResult
+   *   Result of the scaffolding operation (is this file managed or unmanaged, etc.)
+   */
+  public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options);
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OriginalOpAwareInterface.php b/core/lib/Drupal/Component/Scaffold/Operations/OriginalOpAwareInterface.php
new file mode 100644
index 0000000000..0be5b568b7
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OriginalOpAwareInterface.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+/**
+ * Implement OriginalOpAwareInterface to be informed of any op at the same destination path.
+ */
+interface OriginalOpAwareInterface {
+
+  /**
+   * Set a reference to the original scaffold operation at the same destination path.
+   *
+   * @param OperationInterface $originalOp
+   *   The scaffold operation for the source file being appended / prepended.
+   *
+   * @return $this
+   */
+  public function setOriginalOp(OperationInterface $originalOp);
+
+  /**
+   * Return 'true' if an original operation was provided.
+   *
+   * @return bool
+   *   Whether or not an original operation was provided.
+   */
+  public function hasOriginalOp();
+
+  /**
+   * Return the original operation that this op is overriding.
+   *
+   * @return OperationInterface
+   *   The original operation.
+   */
+  public function originalOp();
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OriginalOpAwareTrait.php b/core/lib/Drupal/Component/Scaffold/Operations/OriginalOpAwareTrait.php
new file mode 100644
index 0000000000..37e9f2993d
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OriginalOpAwareTrait.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+/**
+ * Use OriginalOpAwareTrait to be informed of any op at the same destination path.
+ */
+trait OriginalOpAwareTrait {
+  /**
+   * The original operation at the same destination path.
+   *
+   * @var OperationInterface
+   *   The original operation at the same destination path.
+   */
+  protected $originalOp;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setOriginalOp(OperationInterface $originalOp) {
+    $this->originalOp = $originalOp;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function hasOriginalOp() {
+    return isset($this->originalOp);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function originalOp() {
+    return $this->originalOp;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/ReplaceOp.php b/core/lib/Drupal/Component/Scaffold/Operations/ReplaceOp.php
new file mode 100644
index 0000000000..b066c22946
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/ReplaceOp.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Composer\Util\Filesystem;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * Scaffold operation to copy or symlink from source to destination.
+ */
+class ReplaceOp implements OperationInterface {
+  protected $source;
+  protected $overwrite;
+
+  /**
+   * Set the relative path to the source.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $sourcePath
+   *   The relative path to the source file.
+   *
+   * @return $this
+   */
+  public function setSource(ScaffoldFilePath $sourcePath) {
+    $this->source = $sourcePath;
+    return $this;
+  }
+
+  /**
+   * Get the source.
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+   *   The source file reference object.
+   */
+  public function getSource() {
+    return $this->source;
+  }
+
+  /**
+   * Set whether the scaffold file should overwrite existing files at the same path.
+   *
+   * @param bool $overwrite
+   *   Whether to overwrite existing files.
+   *
+   * @return $this
+   */
+  public function setOverwrite($overwrite) {
+    $this->overwrite = $overwrite;
+    return $this;
+  }
+
+  /**
+   * Determine whether scaffold file should overwrite files already at the same path.
+   *
+   * @return bool
+   *   Value of the 'overwrite' option.
+   */
+  public function getOverwrite() {
+    return $this->overwrite;
+  }
+
+  /**
+   * Copy or Symlink the specified scaffold file.
+   *
+   * {@inheritdoc}
+   */
+  public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+    $fs = new Filesystem();
+    $destination_path = $destination->fullPath();
+    // Do nothing if overwrite is 'false' and a file already exists at the destination.
+    if ($this->getOverwrite() === FALSE && file_exists($destination_path)) {
+      $interpolator = $destination->getInterpolator();
+      $io->write($interpolator->interpolate("  - Skip <info>[dest-rel-path]</info> because it already exists and overwrite is <comment>false</comment>."));
+      return (new ScaffoldResult($destination))->setManaged(FALSE);
+    }
+    // Get rid of the destination if it exists, and make sure that
+    // the directory where it's going to be placed exists.
+    @unlink($destination_path);
+    $fs->ensureDirectoryExists(dirname($destination_path));
+    if ($options->symlink() == TRUE) {
+      return $this->symlinkScaffold($destination, $io, $options);
+    }
+    return $this->copyScaffold($destination, $io, $options);
+  }
+
+  /**
+   * Copy the scaffold file.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+   *   Scaffold file to process.
+   * @param \Composer\IO\IOInterface $io
+   *   IOInterface to writing to.
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Various options that may alter the behavior of the operation.
+   */
+  public function copyScaffold(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+    $interpolator = $destination->getInterpolator();
+    $this->getSource()->addInterpolationData($interpolator);
+    $success = copy($this->getSource()->fullPath(), $destination->fullPath());
+    if (!$success) {
+      throw new \Exception($interpolator->interpolate("Could not copy source file <info>[src-rel-path]</info> to <info>[dest-rel-path]</info>!"));
+    }
+    $io->write($interpolator->interpolate("  - Copy <info>[dest-rel-path]</info> from <info>[src-rel-path]</info>"));
+    return (new ScaffoldResult($destination))->setManaged($this->getOverwrite());
+  }
+
+  /**
+   * Symlink the scaffold file.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+   *   Scaffold file to process.
+   * @param \Composer\IO\IOInterface $io
+   *   IOInterface to writing to.
+   * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+   *   Various options that may alter the behavior of the operation.
+   */
+  public function symlinkScaffold(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+    $interpolator = $destination->getInterpolator();
+    $source_path = $this->getSource()->fullPath();
+    $destination_path = $destination->fullPath();
+    try {
+      $fs = new Filesystem();
+      $fs->relativeSymlink($this->getSource()->fullPath(), $destination->fullPath());
+    }
+    catch (\Exception $e) {
+      throw new \Exception($interpolator->interpolate("Could not symlink source file <info>[src-rel-path]</info> to <info>[dest-rel-path]</info>! "), 1, $e);
+    }
+    $io->write($interpolator->interpolate("  - Link <info>[dest-rel-path]</info> from <info>[src-rel-path]</info>"));
+    return (new ScaffoldResult($destination))->setManaged($this->getOverwrite());
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldResult.php b/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldResult.php
new file mode 100644
index 0000000000..0a45b0711e
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldResult.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+
+/**
+ * Record the result of a scaffold operation.
+ */
+class ScaffoldResult {
+  protected $destination;
+  protected $managed;
+
+  /**
+   * ScaffoldResult constructor.
+   *
+   * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+   *   The path to the scaffold file that was processed.
+   */
+  public function __construct(ScaffoldFilePath $destination) {
+    $this->destination = $destination;
+    $this->managed = FALSE;
+  }
+
+  /**
+   * Determine whether this scaffold file is managed.
+   *
+   * @return bool
+   *   Whether the scaffold file was managed by this plugin (scaffolded) or not (skipped).
+   */
+  public function isManaged() {
+    return $this->managed;
+  }
+
+  /**
+   * Recored whether this result was managed or unmanaged.
+   *
+   * @param bool $isManaged
+   *   Whether this result is managed.
+   *
+   * @return $this
+   */
+  public function setManaged($isManaged = TRUE) {
+    $this->managed = $isManaged;
+    return $this;
+  }
+
+  /**
+   * The destination scaffold file that this result refers to.
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+   *   The destination path for the scaffold result.
+   */
+  public function destination() {
+    return $this->destination;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/SkipOp.php b/core/lib/Drupal/Component/Scaffold/Operations/SkipOp.php
new file mode 100644
index 0000000000..d61b26c3a2
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/SkipOp.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\Component\Scaffold\Operations;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+
+/**
+ * Scaffold operation to skip a scaffold file (do nothing).
+ */
+class SkipOp implements OperationInterface {
+
+  /**
+   * Skip the specified scaffold file.
+   *
+   * {@inheritdoc}
+   */
+  public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+    $interpolator = $destination->getInterpolator();
+    $io->write($interpolator->interpolate("  - Skip <info>[dest-rel-path]</info>: disabled"));
+    return (new ScaffoldResult($destination))->setManaged(FALSE);
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Plugin.php b/core/lib/Drupal/Component/Scaffold/Plugin.php
new file mode 100644
index 0000000000..61a39b9c6d
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Plugin.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\Composer;
+use Composer\EventDispatcher\EventSubscriberInterface;
+use Composer\IO\IOInterface;
+use Composer\Installer\PackageEvent;
+use Composer\Installer\PackageEvents;
+use Composer\Plugin\Capable;
+use Composer\Plugin\CommandEvent;
+use Composer\Plugin\PluginInterface;
+use Composer\Plugin\PluginEvents;
+use Composer\Script\Event;
+use Composer\Script\ScriptEvents;
+use Drupal\Component\Scaffold\CommandProvider as ScaffoldCommandProvider;
+
+/**
+ * Composer plugin for handling drupal scaffold.
+ */
+class Plugin implements PluginInterface, EventSubscriberInterface, Capable {
+  /**
+   * The Composer Scaffold handler.
+   *
+   * @var \Drupal\Component\Scaffold\Handler
+   */
+  protected $handler;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function activate(Composer $composer, IOInterface $io) {
+    // We use a separate PluginScripts object. This way we separate
+    // functionality and also avoid some debug issues with the plugin being
+    // copied on initialisation.
+    // @see \Composer\Plugin\PluginManager::registerPackage()
+    $this->handler = new Handler($composer, $io);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCapabilities() {
+    return ['Composer\Plugin\Capability\CommandProvider' => ScaffoldCommandProvider::class];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ScriptEvents::POST_UPDATE_CMD => 'postCmd',
+      PackageEvents::POST_PACKAGE_INSTALL => 'postPackage',
+      PluginEvents::COMMAND => 'onCommand',
+    ];
+  }
+
+  /**
+   * Post command event callback.
+   *
+   * @param \Composer\Script\Event $event
+   *   The Composer event.
+   */
+  public function postCmd(Event $event) {
+    $this->handler->onPostCmdEvent($event);
+  }
+
+  /**
+   * Post package event behaviour.
+   *
+   * @param \Composer\Installer\PackageEvent $event
+   *   Composer package event sent on install/update/remove.
+   */
+  public function postPackage(PackageEvent $event) {
+    $this->handler->onPostPackageEvent($event);
+  }
+
+  /**
+   * Pre command event callback.
+   *
+   * @param \Composer\Plugin\CommandEvent $event
+   *   The Composer command event.
+   */
+  public function onCommand(CommandEvent $event) {
+    if ($event->getCommandName() == 'require') {
+      $this->handler->beforeRequire($event);
+    }
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/README.md b/core/lib/Drupal/Component/Scaffold/README.md
new file mode 100644
index 0000000000..3b40db4080
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/README.md
@@ -0,0 +1,477 @@
+# composer-scaffold
+
+This project provides a composer plugin for placing scaffold files (like
+`index.php`, `update.php`, …) from the `drupal/core` project into their desired
+location inside the web root. Only individual files may be scaffolded with this
+plugin.
+
+The purpose of scaffolding files is to allow Drupal sites to be fully managed by
+Composer, and still allow individual asset files to be placed in arbitrary
+locations. The goal of doing this is to enable a properly configured composer
+template to produce a file layout that exactly matches the file layout of a
+Drupal 8.7.x and earlier tarball distribution. Other file layouts will also be
+possible; for example, a project layout very similar to the current
+[drupal-composer/drupal-project](https://github.com/drupal-composer/drupal-scaffold)
+template will also be provided. When one of these projects is used, the user
+should be able to use `composer require` and `composer update` on a Drupal site
+immediately after untarring the downloaded archive.
+
+Note that the dependencies of a Drupal site are only able to scaffold files if
+explicitly granted that right in the top-level composer.json file. See
+[allowed packages](#allowed-packages), below.
+
+## Usage
+
+Composer-scaffold is used by requiring `drupal/core-composer-scaffold` in your
+project, and providing configuration settings in the `extra` section of your
+project's composer.json file. Additional configuration from the composer.jon
+file of your project's dependencies is also consulted in order to scaffold the
+files a project needs. Additional information may be added to the beginning or
+end of scaffold files, as is commonly done to `.htaccess` and `robots.txt`
+files. See [altering scaffold files](#altering-scaffold-files) for more
+information.
+
+### Allowed Packages
+
+Scaffold files are stored inside of projects that are required from the main
+project's composer.json file as usual. The scaffolding operation happens after
+`composer install`, and involves copying or symlinking the desired assets to
+their destination location. In order to prevent arbitrary dependencies from
+copying files via the scaffold mechanism, only those projects that are
+specifically permitted by the top-level project will be used to scaffold files.
+
+Example: Permit scaffolding from the project `drupal/core`
+```
+  "name": "my/project",
+  ...
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "drupal/core",
+      ],
+      ...
+    }
+  }
+```
+Allowing a package to scaffold files also permits it to delegate permission to
+scaffold to any project that it requires itself. This allows a package to
+organize its scaffold assets as it sees fit. For example, the project
+`drupal/core` may choose to store its assets in a subproject `drupal/assets`.
+
+It is possible for a project to obtain scaffold files from multiple projects.
+For example, a Drupal project using a distribution, and installing on a specific
+web hosting service provider might take its scaffold files from:
+
+- Drupal core
+- Its distribution
+- A project provided by the hosting provider
+- The project itself
+
+Each project allowed to scaffold by the top-level project will be used in turn,
+with projects declared later in the `allowed-packages` list taking precidence
+over the projects named before. The top-level composer.json itself is always
+implicitly allowed to scaffold files, and its scaffold files have highest
+priority.
+
+### Defining Project Locations
+
+The top-level project in turn must define where the web root is located. It does
+so via the `locations` mapping, as shown below:
+```
+  "name": "my/project",
+  ...
+  "extra": {
+    "composer-scaffold": {
+      "locations": {
+        "web-root": "./docroot"
+      },
+      ...
+    }
+  }
+```
+This makes it possible to configure a project with different file layouts; for
+example, either the `drupal/drupal` file layout or the
+`drupal-composer/drupal-project` file layout could be used to set up a project.
+
+If a web-root is not explicitly defined, then it will default to `./`.
+
+### Altering Scaffold Files
+
+Sometimes, a project might wish to use a scaffold file provided by a dependency,
+but alter it in some way. Two forms of alteration are supported: appending and
+patching.
+
+The example below shows a project that appends additional entries onto the end
+of the `robots.txt` file provided by `drupal/core`:
+```
+  "name": "my/project",
+  ...
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/robots.txt": {
+          "append": "assets/my-robots-additions.txt",
+        }
+      }
+    }
+  }
+```
+It is also possible to prepend to a scaffold file instead of, or in addition to
+appending by including a "prepend" entry that provides the relative path to the
+file to prepend to the scaffold file.
+
+The example below demonstrates the use of the `post-composer-scaffold-cmd` hook
+to patch the `.htaccess` file using a patch.
+```
+  "name": "my/project",
+  ...
+  "scripts": {
+    "post-composer-scaffold-cmd": [
+      "cd docroot && patch -p1 <../patches/htaccess-ssl.patch"
+    ]
+  }
+```
+
+### Defining Scaffold Files
+
+The placement of scaffold assets is under the control of the project that
+provides them, but the location is always relative to some directory defined by
+the root project -- usually the web root. For example, the scaffold file
+`robots.txt` is copied from its source location, `assets/robots.txt` into the
+web root in the snippet below.
+```
+{
+  "name": "drupal/assets",
+  ...
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/robots.txt": "assets/robots.txt",
+        ...
+      }
+    }
+  }
+}
+```
+
+### Excluding Scaffold Files
+
+Sometimes, a project might prefer to entirely replace a scaffold file provided
+by a dependency, and receive no further updates for it. This can be done by
+setting the value for the scaffold file to exclude to `false`:
+```
+  "name": "my/project",
+  ...
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/robots.txt": false
+      }
+    }
+  }
+```
+If possible, use the `append` and `prepend` directives as explained in [altering
+scaffold files](#altering-scaffold-files), above. Excluding a file means that
+your project will not get any bug fixes or other updates to files that are
+modified locally.
+
+### Overwrite
+
+By default, scaffold files overwrite whatever content exists at the target
+location. Sometimes a project may wish to provide the initial contents for a
+file that will not be changed in subsequent updates. This can be done by setting
+the `overwrite` flag to `false`, as shown in the example below:
+```
+{
+  "name": "service-provider/d8-scaffold-files",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/sites/default/settings.php": {
+          "mode": "replace",
+          "path": "assets/sites/default/settings.php",
+          "overwrite": false
+        }
+      }
+    }
+  }
+}
+```
+Note that the `overwrite` directive is intended to be used by starter kits,
+service providers, and so on. Individual Drupal sites should exclude the file
+by setting its value to false instead.
+
+## Specifications
+
+Reference section for the configuration directives for the "composer-scaffold"
+section of the "extra" section of a `composer.json` file appear below.
+
+### allowed-packages
+
+The `allowed-packages` configuration setting contains an orderd list of package
+names that will be used during the scaffolding phase.
+```
+"allowed-packages": [
+  "drupal/core",
+],
+```
+### file-mapping
+
+The `file-mapping` configuration setting consists of a map from the destination
+path of the file to scaffold to a set of properties that control how the file
+should be scaffolded.
+
+The available properties are as follows:
+
+- mode: One of "replace", "append" or "skip".
+- path: The path to the source file to write over the destination file.
+- prepend: The path to the source file to prepend to the destination file, which
+  must always be a scaffold file provided by some other project.
+- append: Like `prepend`, but appends content rather than prepends.
+- overwrite: If `false`, prevents a `replace` from happening if the destination
+  already exists.
+
+The mode may be inferred from the other properties. If the mode is not
+specified, then the following defaults will be supplied:
+
+- replace: Selected if a `path` property is present, or if the entry's value is
+  a string rather than a property set.
+- append: Selected if a `prepend` or `append` property is present.
+- skip: Selected if the entry's value is a boolean `false`.
+
+Examples:
+```
+"file-mapping": {
+  "[web-root]/sites/default/default.settings.php": {
+    "mode": "replace",
+    "path": "assets/sites/default/default.settings.php",
+    "overwrite": true
+  },
+  "[web-root]/sites/default/settings.php": {
+    "mode": "replace",
+    "path": "assets/sites/default/settings.php",
+    "overwrite": false
+  },
+  "[web-root]/robots.txt": {
+    "mode": "append",
+    "prepend": "assets/robots-prequel.txt",
+    "append": "assets/robots-append.txt"
+  },
+  "[web-root]/.htaccess": {
+    "mode": "skip",
+  }
+}
+```
+The short-form of the above example would be:
+```
+"file-mapping": {
+  "[web-root]/sites/default/default.settings.php": "assets/sites/default/default.settings.php",
+  "[web-root]/sites/default/settings.php": {
+    "path": "assets/sites/default/settings.php",
+    "overwrite": false
+  },
+  "[web-root]/robots.txt": {
+    "prepend": "assets/robots-prequel.txt",
+    "append": "assets/robots-append.txt"
+  },
+  "[web-root]/.htaccess": false
+}
+```
+Note that there is no distinct "prepend" mode; "append" mode is used to both
+append and prepend to scaffold files. The reason for this is that scaffold file
+entries are identified in the file-mapping section keyed by their destination
+path, and it is not possible for multiple entries to have the same key. If
+"prepend" were a separate mode, then it would not be possible to both prepend
+and append to the same file.
+
+### gitignore
+
+The `gitignore` configuration setting controls whether or not this plugin will
+manage `.gitignore` files for files written during the scaffold operation.
+
+- true: `.gitignore` files will be updated when scaffold files are written.
+- false: `.gitignore` files will never be modified.
+- Not set: `.gitignore` files will be updated if the target directory is a local
+working copy of a git repository, and the `vendor` directory is not committed
+in that repository.
+
+### locations
+
+The `locations` configuration setting contains a list of named locations that
+may be used in placing scaffold files. The only required location is `web-root`.
+Other locations may also be defined if desired.
+```
+"locations": {
+  "web-root": "./docroot"
+},
+```
+### symlink
+
+The `symlink` property causes `replace` operations to make a symlink to the
+source file rather than copying it. This is useful when doing core development,
+as the symlink files themselves should not be edited. Note that `append`
+operations override the `symlink` option, to prevent the original scaffold
+assets from being altered.
+```
+"symlink": true,
+```
+## Managing Scaffold Files
+
+Scaffold files should be treated the same way that the `vendor` directory is
+handled. If you need to commit `vendor` (e.g. in order to deploy your site),
+then you should also commit your scaffold files. You should not commit your
+`vendor` directory or scaffold files unless it is necessary.
+
+If a dependency provides a scaffold file with `overwrite` set to `false`, that
+file should be committed to your repository.
+
+By default, `.gitignore` files will be automatically updated if needed when
+scaffold files are written. See the `gitignore` setting in the Specifications
+section above.
+
+## Examples
+
+Some full-length examples appear below.
+
+Sample composer.json for a project that relies on packages that use composer-scaffold:
+```
+{
+  "name": "my/project",
+  "require": {
+    "drupal/composer-scaffold": "*",
+    "composer/installers": "^1.2",
+    "cweagans/composer-patches": "^1.6.5",
+    "drupal/core": "^8.8.x-dev",
+    "service-provider/d8-scaffold-files": "^1"
+  },
+  "config": {
+    "optimize-autoloader": true,
+    "sort-packages": true
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "drupal/core",
+      ],
+      "locations": {
+        "web-root": "./docroot"
+      },
+      "symlink": true,
+      "overwrite": true,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/robots-default.txt"
+      }
+    }
+  }
+}
+```
+
+Sample composer.json for drupal/core, with assets placed in a different project:
+
+```
+{
+  "name": "drupal/core",
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "drupal/assets",
+      ]
+    }
+  }
+}
+```
+
+Sample composer.json for composer-scaffold files in drupal/assets:
+
+```
+{
+  "name": "drupal/assets",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/.csslintrc": "assets/.csslintrc",
+        "[web-root]/.editorconfig": "assets/.editorconfig",
+        "[web-root]/.eslintignore": "assets/.eslintignore",
+        "[web-root]/.eslintrc.json": "assets/.eslintrc.json",
+        "[web-root]/.gitattributes": "assets/.gitattributes",
+        "[web-root]/.ht.router.php": "assets/.ht.router.php",
+        "[web-root]/.htaccess": "assets/.htaccess",
+        "[web-root]/sites/default/default.services.yml": "assets/default.services.yml",
+        "[web-root]/sites/default/default.settings.php": "assets/default.settings.php",
+        "[web-root]/sites/example.settings.local.php": "assets/example.settings.local.php",
+        "[web-root]/sites/example.sites.php": "assets/example.sites.php",
+        "[web-root]/index.php": "assets/index.php",
+        "[web-root]/robots.txt": "assets/robots.txt",
+        "[web-root]/update.php": "assets/update.php",
+        "[web-root]/web.config": "assets/web.config"
+      }
+    }
+  }
+}
+```
+
+Sample composer.json for a library that implements composer-scaffold:
+
+```
+{
+  "name": "service-provider/d8-scaffold-files",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/sites/default/settings.php": "assets/sites/default/settings.php"
+      }
+    }
+  }
+}
+```
+
+Append to robots.txt:
+
+```
+{
+  "name": "service-provider/d8-scaffold-files",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/robots.txt": {
+          "append": "assets/my-robots-additions.txt",
+        }
+      }
+    }
+  }
+}
+```
+
+Patch a file after it's copied:
+
+```
+"post-composer-scaffold-cmd": [
+  "cd docroot && patch -p1 <../patches/htaccess-ssl.patch"
+]
+```
+
+## Related Plugins
+
+### drupal-composer/drupal-scaffold
+
+Previous versions of drupal-scaffold (see community project,
+[drupal-composer/drupal-scaffold](https://github.com/drupal-composer/drupal-project))
+downloaded each scaffold file directly from its distribution server (e.g.
+`https://cgit.drupalcode.org`) to the desired destination directory. This was
+necessary, because there was no subtree split of the scaffold files available.
+Copying the scaffold assets from projects already downloaded by Composer is more
+effective, as downloading and unpacking archive files is more efficient than
+downloading each scaffold file individually.
+
+### composer/installers
+
+The [composer/installers](https://github.com/composer/installers) plugin is
+similar to this plugin in that it allows dependencies to be installed in
+locations other than the `vendor` directory. However, Composer and the
+`composer/installers` plugin have a limitation that one project cannot be moved
+inside of another project. Therefore, if you use `composer/installers` to place
+Drupal modules inside the directory `web/modules/contrib`, then you cannot also
+use `composer/installers` to place files such as `index.php` and `robots.txt`
+into the `web` directory. The drupal-scaffold plugin was created to work around
+this limitation.
diff --git a/core/lib/Drupal/Component/Scaffold/ScaffoldFileInfo.php b/core/lib/Drupal/Component/Scaffold/ScaffoldFileInfo.php
new file mode 100644
index 0000000000..16da96de42
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ScaffoldFileInfo.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+use Composer\IO\IOInterface;
+use Drupal\Component\Scaffold\Operations\OperationInterface;
+
+/**
+ * Data object that keeps track of one scaffold file.
+ *
+ * Scafold files are identified primariy by their destination path. Each
+ * scaffold file also has an 'operation' object that controls how the
+ * scaffold file will be placed (e.g. via copy or symlink, or maybe by
+ * appending multiple files together).  The operation may have one or more
+ * source files.
+ */
+class ScaffoldFileInfo {
+  protected $destination;
+  protected $op;
+
+  /**
+   * Set the Scaffold operation.
+   *
+   * @param \Drupal\Component\Scaffold\Operations\OperationInterface $op
+   *   Operations object that will handle scaffolding operations.
+   *
+   * @return $this
+   */
+  public function setOp(OperationInterface $op) {
+    $this->op = $op;
+    return $this;
+  }
+
+  /**
+   * Get the Scaffold operation.
+   *
+   * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+   *   Operations object that handles scaffolding (copy, make symlink, etc).
+   */
+  public function op() {
+    return $this->op;
+  }
+
+  /**
+   * Get the package name.
+   *
+   * @return string
+   *   The name of the package this scaffold file info was collected from.
+   */
+  public function packageName() {
+    return $this->destination->packageName();
+  }
+
+  /**
+   * Set the relative path to the destination.
+   *
+   * @param \Drupal\Component\Scaffold\Operations\ScaffoldFilePath $destination
+   *   The full and relative paths to the destination file and the package defining it.
+   *
+   * @return $this
+   */
+  public function setDestination(ScaffoldFilePath $destination) {
+    $this->destination = $destination;
+    return $this;
+  }
+
+  /**
+   * Get the destination.
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+   *   The scaffold path to the destination file.
+   */
+  public function destination() {
+    return $this->destination;
+  }
+
+  /**
+   * Determine if this scaffold file has been overridden by another package.
+   *
+   * @param string $providing_package
+   *   The name of the package that provides the scaffold file at this location,
+   *   as returned by self::findProvidingPackage()
+   *
+   * @return bool
+   *   Whether this scaffold file if overridden or removed.
+   */
+  public function overridden($providing_package) {
+    return $this->packageName() !== $providing_package;
+  }
+
+  /**
+   * Interpolate a string using the data from this scaffold file info.
+   *
+   * @return Interpolator
+   *   An interpolator for making string replacements.
+   */
+  public function getInterpolator() {
+    return $this->destination->getInterpolator();
+  }
+
+  /**
+   * Given a message with placeholders, return the interpolated result.
+   *
+   * @param string $message
+   *   Message with placeholders to fill in.
+   * @param array $extra
+   *   Additional data to merge with the interpolator.
+   * @param mixed $default
+   *   Default value to use for missing placeholders, or FALSE to keep them.
+   *
+   * @return string
+   *   Interpolated string with placeholders replaced.
+   */
+  public function interpolate($message, array $extra = [], $default = FALSE) {
+    $interpolator = $this->getInterpolator();
+    return $interpolator->interpolate($message, $extra, $default);
+  }
+
+  /**
+   * Moves a single scaffold file from source to destination.
+   *
+   * @param \Composer\IO\IOInterface $io
+   *   The scaffold file to be processed.
+   * @param ScaffoldOptions $options
+   *   Assorted operational options, e.g. whether the destination should be a symlink.
+   *
+   * @throws \Exception
+   */
+  public function process(IOInterface $io, ScaffoldOptions $options) {
+    return $this->op()->process($this->destination, $io, $options);
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/ScaffoldFilePath.php b/core/lib/Drupal/Component/Scaffold/ScaffoldFilePath.php
new file mode 100644
index 0000000000..10e960a33e
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ScaffoldFilePath.php
@@ -0,0 +1,181 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+/**
+ * Manage the path to a file to scaffold.
+ *
+ * Both the relative and full path to the file is maintained so that the
+ * shorter name may be used in progress and error messages, as needed.
+ * The name of the package that provided the file path is also recorded
+ * for the same reason.
+ *
+ * ScaffoldFilePaths may be used to represent destination scaffold files,
+ * or the source files used to create them. Static factory methods named
+ * destinationPath and sourcePath, respectively, are provided to create
+ * ScafoldFilePath objects.
+ */
+class ScaffoldFilePath {
+  protected $type;
+  protected $packageName;
+  protected $relPath;
+  protected $fullPath;
+
+  /**
+   * ScaffoldFilePath constructor.
+   *
+   * @param string $path_type
+   *   The type of scaffold file this is, 'src' or 'dest'.
+   * @param string $package_name
+   *   The name of the package containing the file.
+   * @param string $rel_path
+   *   The relative path to the file.
+   * @param string $full_path
+   *   The full path to the file.
+   */
+  public function __construct($path_type, $package_name, $rel_path, $full_path) {
+    $this->type = $path_type;
+    $this->packageName = $package_name;
+    $this->relPath = $rel_path;
+    $this->fullPath = $full_path;
+  }
+
+  /**
+   * The name of the package this source file was pulled from.
+   *
+   * @return string
+   *   Name of package.
+   */
+  public function packageName() {
+    return $this->packageName;
+  }
+
+  /**
+   * The relative path to the source file (best to use in messages).
+   *
+   * @return string
+   *   Relative path to file.
+   */
+  public function relativePath() {
+    return $this->relPath;
+  }
+
+  /**
+   * The full path to the source file.
+   *
+   * @return string
+   *   Full path to file.
+   */
+  public function fullPath() {
+    return $this->fullPath;
+  }
+
+  /**
+   * Convert the relative source path into an absolute path.
+   *
+   * The path returned will be relative to the package installation location.
+   *
+   * @param string $package_name
+   *   The name of the package containing the source file. Only used for error messages.
+   * @param string $package_path
+   *   The installation path of the package containing the source file.
+   * @param string $destination
+   *   Destination location provided as a relative path. Only used for error messages.
+   * @param string $source
+   *   Source location provided as a relative path.
+   *
+   * @return self
+   *   Object wrapping the relative and absolute path to the source file.
+   */
+  public static function sourcePath($package_name, $package_path, $destination, $source) {
+    // Complain if there is no source path.
+    if (empty($source)) {
+      throw new \Exception("No scaffold file path given for {$destination} in package {$package_name}.");
+    }
+    // Calculate the full path to the source scaffold file.
+    $source_full_path = $package_path . '/' . $source;
+    if (!file_exists($source_full_path)) {
+      throw new \Exception("Scaffold file {$source} not found in package {$package_name}.");
+    }
+    if (is_dir($source_full_path)) {
+      throw new \Exception("Scaffold file {$source} in package {$package_name} is a directory; only files may be scaffolded.");
+    }
+    return new self('src', $package_name, $source, $source_full_path);
+  }
+
+  /**
+   * Convert the relative destination path into an absolute path.
+   *
+   * Any placeholders in the destination path, e.g. '[web-root]', will be
+   * replaced using the provided location replacements interpolator.
+   *
+   * @param string $package_name
+   *   The name of the package defining the destination path.
+   * @param string $destination
+   *   The relative path to the destination file being scaffolded.
+   * @param \Drupal\Component\Scaffold\Interpolator $locationReplacements
+   *   Interpolator that includes the [web-root] and any other available
+   *   placeholder replacements.
+   *
+   * @return self
+   *   Object wrapping the relative and absolute path to the destination file.
+   */
+  public static function destinationPath($package_name, $destination, Interpolator $locationReplacements) {
+    $dest_full_path = $locationReplacements->interpolate($destination);
+    return new self('dest', $package_name, $destination, $dest_full_path);
+  }
+
+  /**
+   * Generate a scaffold file path object for the autoload file.
+   *
+   * @param string $package_name
+   *   The name of the package defining the autoload file (the root package).
+   * @param string $web_root
+   *   The path to the web root.
+   *
+   * @return self
+   *   Object wrapping the relative and absolute path to the destination file.
+   */
+  public static function autoloadPath($package_name, $web_root) {
+    $rel_path = 'autoload.php';
+    $dest_rel_path = '[web-root]/' . $rel_path;
+    $dest_full_path = $web_root . '/' . $rel_path;
+    return new self('autoload', $package_name, $dest_rel_path, $dest_full_path);
+  }
+
+  /**
+   * Add data about the relative and full path to this item to the provided interpolator.
+   *
+   * @param \Drupal\Component\Scaffold\Interpolator $interpolator
+   *   Interpolator to add data to.
+   * @param string $namePrefix
+   *   Prefix to add before -rel-path and -full-path item names. Defaults to path type.
+   */
+  public function addInterpolationData(Interpolator $interpolator, $namePrefix = '') {
+    if (empty($namePrefix)) {
+      $namePrefix = $this->type;
+    }
+    $data = [
+      'package-name' => $this->packageName(),
+      "{$namePrefix}-rel-path" => $this->relativePath(),
+      "{$namePrefix}-full-path" => $this->fullPath(),
+    ];
+    $interpolator->addData($data);
+  }
+
+  /**
+   * Interpolate a string using the data from this scaffold file info.
+   *
+   * @param string $namePrefix
+   *   Prefix to add before -rel-path and -full-path item names. Defaults to path type.
+   *
+   * @return \Drupal\Component\Scaffold\Interpolator
+   *   An interpolator for making string replacements.
+   */
+  public function getInterpolator($namePrefix = '') {
+    $interpolator = new Interpolator();
+    $this->addInterpolationData($interpolator, $namePrefix);
+    return $interpolator;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/ScaffoldOptions.php b/core/lib/Drupal/Component/Scaffold/ScaffoldOptions.php
new file mode 100644
index 0000000000..4b970d5a00
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ScaffoldOptions.php
@@ -0,0 +1,214 @@
+<?php
+
+namespace Drupal\Component\Scaffold;
+
+/**
+ * Per-project options from the 'extras' section of the composer.json file.
+ *
+ * Projects that describe scaffold files do so via their scaffold options.
+ * This data is pulled from the 'composer-scaffold' portion of the extras
+ * section of the project data.
+ */
+class ScaffoldOptions {
+  protected $options = [];
+
+  /**
+   * ScaffoldOptions constructor.
+   *
+   * @param array $options
+   *   The scaffold options taken from the 'composer-scaffold' section.
+   */
+  protected function __construct(array $options) {
+    $this->options = $options + [
+      "allowed-packages" => [],
+      "locations" => [],
+      "symlink" => FALSE,
+      "file-mapping" => [],
+    ];
+  }
+
+  /**
+   * Determine if the provided 'extras' section has scaffold options.
+   *
+   * @param array $extras
+   *   The contents of the 'extras' section.
+   *
+   * @return bool
+   *   True if scaffold options have been declared
+   */
+  public static function hasOptions(array $extras) {
+    return array_key_exists('composer-scaffold', $extras);
+  }
+
+  /**
+   * Create a scaffold options object.
+   *
+   * @param array $extras
+   *   The contents of the 'extras' section.
+   *
+   * @return self
+   *   The scaffold options object representing the provided scaffold options
+   */
+  public static function create(array $extras) {
+    $options = static::hasOptions($extras) ? $extras['composer-scaffold'] : [];
+    return new self($options);
+  }
+
+  /**
+   * Create a scaffold option object with default values.
+   *
+   * @return self
+   *   A scaffold options object with default values
+   */
+  public static function defaultOptions() {
+    return new self([]);
+  }
+
+  /**
+   * Create a new scaffold options object with some values overridden.
+   *
+   * @param array $options
+   *   Override values.
+   *
+   * @return self
+   *   The scaffold options object representing the provided scaffold options
+   */
+  protected function override(array $options) {
+    return new self($options + $this->options);
+  }
+
+  /**
+   * Create a new scaffold options object with a new value in the 'symlink' variable.
+   *
+   * @return self
+   *   The scaffold options object representing the provided scaffold options
+   */
+  public function overrideSymlink($symlink) {
+    return $this->override(['symlink' => $symlink]);
+  }
+
+  /**
+   * Determine whether any allowed packages were defined.
+   *
+   * @return bool
+   *   Whether there are allowed packages
+   */
+  public function hasAllowedPackages() {
+    return !empty($this->allowedPackages());
+  }
+
+  /**
+   * The allowed packages from these options.
+   *
+   * @return array
+   *   The list of allowed packages
+   */
+  public function allowedPackages() {
+    return $this->options['allowed-packages'];
+  }
+
+  /**
+   * The location mapping table, e.g. 'webroot' => './'.
+   *
+   * @return array
+   *   A map of name : location values
+   */
+  public function locations() {
+    return $this->options['locations'];
+  }
+
+  /**
+   * Determine whether a given named location is defined.
+   *
+   * @return bool
+   *   True if the specified named location exist.
+   */
+  public function hasLocation($name) {
+    return array_key_exists($name, $this->locations());
+  }
+
+  /**
+   * Get a specific named location.
+   *
+   * @param string $name
+   *   The name of the location to fetch.
+   * @param string $default
+   *   The value to return if the requested location is not defined.
+   *
+   * @return string
+   *   The value of the provided named location
+   */
+  public function getLocation($name, $default = '') {
+    return $this->hasLocation($name) ? $this->locations()[$name] : $default;
+  }
+
+  /**
+   * Return the value of a specific named location, or throw.
+   *
+   * @param string $name
+   *   The name of the location to fetch.
+   * @param string $message
+   *   The message to pass into the exception if the requested location
+   *   does not exist.
+   *
+   * @return string
+   *   The value of the provided named location
+   */
+  public function requiredLocation($name, $message) {
+    if (!$this->hasLocation($name)) {
+      throw new \Exception($message);
+    }
+    return $this->getLocation($name);
+  }
+
+  /**
+   * Determine whether the options have defined symlink mode.
+   *
+   * @return bool
+   *   Whether or not 'symlink' mode
+   */
+  public function symlink() {
+    return $this->options['symlink'];
+  }
+
+  /**
+   * Determine whether these options contain file mappings.
+   *
+   * @return bool
+   *   Whether or not the scaffold options contain any file mappings
+   */
+  public function hasFileMapping() {
+    return !empty($this->fileMapping());
+  }
+
+  /**
+   * Return the actual file mappings.
+   *
+   * @return array
+   *   File mappings for just this config type.
+   */
+  public function fileMapping() {
+    return $this->options['file-mapping'];
+  }
+
+  /**
+   * Whether the scaffold options have defined a value for the 'gitignore' option.
+   *
+   * @return bool
+   *   Whether or not there is a 'gitignore' option setting
+   */
+  public function hasGitIgnore() {
+    return isset($this->options['gitignore']);
+  }
+
+  /**
+   * The value of the 'gitignore' option.
+   *
+   * @return bool
+   *   The 'gitignore' option, or TRUE if undefined.
+   */
+  public function gitIgnore() {
+    return $this->hasGitIgnore() ? $this->options['gitignore'] : TRUE;
+  }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/TESTING.txt b/core/lib/Drupal/Component/Scaffold/TESTING.txt
new file mode 100644
index 0000000000..3186f0f901
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/TESTING.txt
@@ -0,0 +1,18 @@
+HOW-TO: Test this Drupal component
+
+In order to test this component, you'll need to get the entire Drupal repo and
+run the tests there.
+
+You'll find the tests under core/tests/Drupal/Tests/Component.
+
+You can get the full Drupal repo here:
+https://www.drupal.org/project/drupal/git-instructions
+
+You can find more information about running PHPUnit tests with Drupal here:
+https://www.drupal.org/node/2116263
+
+Each component in the Drupal\Component namespace has its own annotated test
+group. You can use this group to run only the tests for this component. Like
+this:
+
+$ ./vendor/bin/phpunit -c core --group Scaffold
diff --git a/core/lib/Drupal/Component/Scaffold/composer.json b/core/lib/Drupal/Component/Scaffold/composer.json
new file mode 100644
index 0000000000..cc7e8967c0
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/composer.json
@@ -0,0 +1,45 @@
+{
+  "name": "drupal/core-composer-scaffold",
+  "description": "A flexible Composer project scaffold builder.",
+  "type": "composer-plugin",
+  "keywords": ["drupal"],
+  "homepage": "https://www.drupal.org/project/drupal",
+  "license": "GPL-2.0-or-later",
+  "require": {
+    "composer-plugin-api": "^1.0.0",
+    "php": ">=7.0.8"
+  },
+  "autoload": {
+    "psr-4": {
+      "Drupal\\Component\\Scaffold\\": ""
+    }
+  },
+  "autoload-dev": {
+    "psr-4": {
+      "Drupal\\Tests\\Component\\Scaffold\\": "tests/src"
+    }
+  },
+  "extra": {
+    "class": "Drupal\\Component\\Scaffold\\Plugin",
+    "branch-alias": {
+      "dev-master": "1.0.x-dev"
+    }
+  },
+  "scripts": {
+    "phpcs": "phpcs --standard=phpcs.xml.dist --runtime-set installed_paths $($COMPOSER_BINARY config vendor-dir)/drupal/coder/coder_sniffer --",
+    "phpcbf": "phpcbf --standard=phpcs.xml.dist --runtime-set installed_paths $($COMPOSER_BINARY config vendor-dir)/drupal/coder/coder_sniffer --",
+    "unit": "phpunit --colors=always",
+    "test": [
+      "@phpcs",
+      "@unit"
+    ]
+  },
+  "config": {
+    "sort-packages": true
+  },
+  "require-dev": {
+    "composer/composer": "^1.8@stable",
+    "drupal/coder": "^8.3.2",
+    "phpunit/phpunit": "^4.8.35 || ^6.5"
+  }
+}
diff --git a/core/lib/Drupal/Component/Scaffold/phpcs.xml.dist b/core/lib/Drupal/Component/Scaffold/phpcs.xml.dist
new file mode 100644
index 0000000000..8a4a8ffea7
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/phpcs.xml.dist
@@ -0,0 +1,323 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ruleset name="drupal_core">
+  <description>Default PHP CodeSniffer configuration for Drupal core.</description>
+  <file>.</file>
+  <file>tests/src</file>
+  <arg name="extensions" value="inc,install,module,php,profile,test,theme,yml"/>
+
+  <!--Exclude third party code.-->
+  <exclude-pattern>./vendor/*</exclude-pattern>
+
+  <!-- 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: CommaLastItem -->
+    <exclude name="Drupal.Arrays.Array.ArrayClosingIndentation"/>
+    <exclude name="Drupal.Arrays.Array.ArrayIndentation"/>
+    <exclude name="Drupal.Arrays.Array.LongLineDeclaration"/>
+  </rule>
+  <rule ref="Drupal.Classes.ClassCreateInstance"/>
+  <rule ref="Drupal.Classes.ClassDeclaration"/>
+  <rule ref="Drupal.Classes.FullyQualifiedNamespace"/>
+  <rule ref="Drupal.Classes.InterfaceName"/>
+  <rule ref="Drupal.Classes.UnusedUseStatement"/>
+  <rule ref="Drupal.Classes.UseLeadingBackslash"/>
+  <rule ref="Drupal.CSS.ClassDefinitionNameSpacing"/>
+  <rule ref="Drupal.CSS.ColourDefinition"/>
+  <rule ref="Drupal.Commenting.ClassComment">
+    <exclude name="Drupal.Commenting.ClassComment.Missing"/>
+  </rule>
+  <rule ref="Drupal.Commenting.DataTypeNamespace"/>
+  <rule ref="Drupal.Commenting.DocComment">
+    <!-- Sniff for these errors: SpacingAfterTagGroup, WrongEnd, SpacingBetween,
+      ContentAfterOpen, SpacingBeforeShort, TagValueIndent, ShortStartSpace,
+      SpacingAfter, LongNotCapital -->
+    <!-- ParamNotFirst still not decided for PHPUnit-based tests.
+      @see https://www.drupal.org/node/2253915 -->
+    <exclude name="Drupal.Commenting.DocComment.ParamNotFirst"/>
+    <exclude name="Drupal.Commenting.DocComment.SpacingBeforeTags"/>
+    <exclude name="Drupal.Commenting.DocComment.LongFullStop"/>
+    <exclude name="Drupal.Commenting.DocComment.ShortNotCapital"/>
+    <exclude name="Drupal.Commenting.DocComment.ShortFullStop"/>
+    <!-- TagsNotGrouped and ParamGroup have false-positives.
+      @see https://www.drupal.org/node/2060925 -->
+    <exclude name="Drupal.Commenting.DocComment.TagsNotGrouped"/>
+    <exclude name="Drupal.Commenting.DocComment.ParamGroup"/>
+    <exclude name="Drupal.Commenting.DocComment.ShortSingleLine"/>
+    <exclude name="Drupal.Commenting.DocComment.TagGroupSpacing"/>
+    <exclude name="Drupal.Commenting.DocComment.MissingShort"/>
+  </rule>
+  <rule ref="Drupal.Commenting.DocCommentStar"/>
+  <rule ref="Drupal.Commenting.FileComment"/>
+  <rule ref="Drupal.Commenting.FunctionComment">
+    <exclude name="Drupal.Commenting.FunctionComment.IncorrectTypeHint"/>
+    <exclude name="Drupal.Commenting.FunctionComment.InvalidNoReturn"/>
+    <exclude name="Drupal.Commenting.FunctionComment.InvalidTypeHint"/>
+    <exclude name="Drupal.Commenting.FunctionComment.Missing"/>
+    <exclude name="Drupal.Commenting.FunctionComment.MissingParamComment"/>
+    <exclude name="Drupal.Commenting.FunctionComment.MissingParamType"/>
+    <exclude name="Drupal.Commenting.FunctionComment.MissingReturnComment"/>
+    <exclude name="Drupal.Commenting.FunctionComment.MissingReturnType"/>
+    <exclude name="Drupal.Commenting.FunctionComment.ParamCommentFullStop"/>
+    <exclude name="Drupal.Commenting.FunctionComment.ParamMissingDefinition"/>
+    <exclude name="Drupal.Commenting.FunctionComment.TypeHintMissing"/>
+  </rule>
+  <rule ref="Drupal.Commenting.GenderNeutralComment" />
+  <rule ref="Drupal.Commenting.VariableComment">
+    <!-- Sniff for: DuplicateVar, EmptyVar, InlineVariableName -->
+    <exclude name="Drupal.Commenting.VariableComment.IncorrectVarType"/>
+    <exclude name="Drupal.Commenting.VariableComment.MissingVar"/>
+    <exclude name="Drupal.Commenting.VariableComment.VarOrder"/>
+    <exclude name="Drupal.Commenting.VariableComment.WrongStyle"/>
+  </rule>
+  <rule ref="Drupal.Commenting.InlineComment">
+    <!-- Sniff for: NoSpaceBefore, WrongStyle -->
+    <exclude name="Drupal.Commenting.InlineComment.DocBlock"/>
+    <exclude name="Drupal.Commenting.InlineComment.InvalidEndChar"/>
+    <exclude name="Drupal.Commenting.InlineComment.NotCapital"/>
+    <exclude name="Drupal.Commenting.InlineComment.SpacingAfter"/>
+    <exclude name="Drupal.Commenting.InlineComment.SpacingBefore"/>
+  </rule>
+  <rule ref="Drupal.Commenting.PostStatementComment"/>
+  <rule ref="Drupal.ControlStructures.ElseIf"/>
+  <rule ref="Drupal.ControlStructures.ControlSignature"/>
+  <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.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.ValidVariableName">
+    <!-- Sniff for: LowerStart -->
+    <exclude name="Drupal.NamingConventions.ValidVariableName.LowerCamelName"/>
+  </rule>
+  <rule ref="Drupal.Scope.MethodScope"/>
+  <rule ref="Drupal.Semantics.EmptyInstall"/>
+  <rule ref="Drupal.Semantics.FunctionAlias"/>
+  <rule ref="Drupal.Semantics.FunctionT">
+    <exclude name="Drupal.Semantics.FunctionT.NotLiteralString"/>
+    <exclude name="Drupal.Semantics.FunctionT.ConcatString"/>
+  </rule>
+  <rule ref="Drupal.Semantics.FunctionWatchdog"/>
+  <rule ref="Drupal.Semantics.InstallHooks"/>
+  <rule ref="Drupal.Semantics.LStringTranslatable"/>
+  <rule ref="Drupal.Semantics.PregSecurity"/>
+  <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.OperatorSpacing"/>
+  <rule ref="Drupal.WhiteSpace.ScopeClosingBrace"/>
+  <rule ref="Drupal.WhiteSpace.ScopeIndent"/>
+
+  <!-- Drupal Practice sniffs -->
+  <rule ref="DrupalPractice.Commenting.ExpectedException"/>
+
+  <!-- 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.SpaceAfterCast"/>
+  <rule ref="Generic.Functions.FunctionCallArgumentSpacing"/>
+  <rule ref="Generic.Functions.OpeningFunctionBraceKernighanRitchie">
+    <properties>
+      <property name="checkClosures" value="true"/>
+    </properties>
+  </rule>
+  <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.UseIncludeOnce">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Files.IncludingFile.UseInclude">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Files.IncludingFile.UseRequireOnce">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Files.IncludingFile.UseRequire">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Functions.ValidDefaultValue"/>
+
+  <!-- PEAR sniffs -->
+  <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. -->
+  <!-- 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>
+  <!-- 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>
+  <rule ref="PEAR.Functions.FunctionCallSignature.ContentAfterOpenBracket">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Functions.FunctionCallSignature.CloseBracketLine">
+    <severity>0</severity>
+  </rule>
+  <rule ref="PEAR.Functions.FunctionCallSignature.EmptyLine">
+    <severity>0</severity>
+  </rule>
+
+  <!-- PSR-2 sniffs -->
+  <rule ref="PSR2.Classes.PropertyDeclaration">
+    <exclude name="PSR2.Classes.PropertyDeclaration.Underscore"/>
+  </rule>
+  <rule ref="PSR2.Namespaces.NamespaceDeclaration"/>
+  <rule ref="PSR2.Namespaces.UseDeclaration">
+    <exclude name="PSR2.Namespaces.UseDeclaration.UseAfterNamespace"/>
+  </rule>
+
+  <!-- Squiz sniffs -->
+  <rule ref="Squiz.Arrays.ArrayBracketSpacing"/>
+  <rule ref="Squiz.Arrays.ArrayDeclaration">
+    <exclude name="Squiz.Arrays.ArrayDeclaration.NoKeySpecified"/>
+    <exclude name="Squiz.Arrays.ArrayDeclaration.KeySpecified"/>
+  </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.ValueNotAligned">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Arrays.ArrayDeclaration.ValueNoNewline">
+    <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.Functions.MultiLineFunctionDeclaration"/>
+  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.BraceOnSameLine">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.ContentAfterBrace">
+    <severity>0</severity>
+  </rule>
+  <!-- Standard yet to be finalized on this (https://www.drupal.org/node/1539712). -->
+  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.FirstParamSpacing">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.Indent">
+    <severity>0</severity>
+  </rule>
+  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.CloseBracketLine">
+    <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.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.SemicolonSpacing"/>
+  <rule ref="Squiz.WhiteSpace.SuperfluousWhitespace"/>
+
+  <!-- Zend sniffs -->
+  <rule ref="Zend.Files.ClosingTag"/>
+
+</ruleset>
diff --git a/core/lib/Drupal/Component/Scaffold/phpunit.xml.dist b/core/lib/Drupal/Component/Scaffold/phpunit.xml.dist
new file mode 100644
index 0000000000..f654b09e4e
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/phpunit.xml.dist
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit
+  bootstrap="vendor/autoload.php"
+  colors="true"
+  processIsolation="true">
+  <testsuites>
+    <testsuite name="site-process">
+      <directory prefix="" suffix="Test.php">tests</directory>
+    </testsuite>
+  </testsuites>
+  <logging>
+      <!--
+      <log type="coverage-html" target="build/logs/coverage" lowUpperBound="35"
+       highLowerBound="70"/>
+      -->
+      <log type="coverage-text"/>
+  </logging>
+  <filter>
+    <whitelist processUncoveredFilesFromWhitelist="true">
+      <directory suffix=".php">.</directory>
+    </whitelist>
+    <blacklist>
+      <directory>./vendor</directory>
+    </blacklist>
+  </filter>
+</phpunit>
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/AssertUtilsTrait.php b/core/tests/Drupal/Tests/Component/Scaffold/AssertUtilsTrait.php
new file mode 100644
index 0000000000..39d0165c5b
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/AssertUtilsTrait.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold;
+
+/**
+ * Convenience class for creating fixtures.
+ */
+trait AssertUtilsTrait {
+
+  /**
+   * Asserts that a given file exists and is/is not a symlink.
+   */
+  protected function assertScaffoldedFile($path, $is_link, $contents_contains) {
+    $this->assertFileExists($path);
+    $contents = file_get_contents($path);
+    $this->assertRegExp($contents_contains, basename($path) . ': ' . $contents);
+    $this->assertSame($is_link, is_link($path));
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/ExecTrait.php b/core/tests/Drupal/Tests/Component/Scaffold/ExecTrait.php
new file mode 100644
index 0000000000..14c44ee6f4
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/ExecTrait.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold;
+
+use Symfony\Component\Process\Process;
+
+/**
+ * Convenience class for creating fixtures.
+ */
+trait ExecTrait {
+
+  /**
+   * Runs an arbitrary command.
+   *
+   * @param string $cmd
+   *   The command to execute (escaped as required)
+   * @param string $cwd
+   *   The current working directory to run the command from.
+   * @param array $env
+   *   Environment variables to define for the subprocess.
+   *
+   * @return array
+   *   Standard output and standard error from the command
+   */
+  protected function mustExec($cmd, $cwd, array $env = []) {
+    $process = new Process($cmd, $cwd, $env + ['PATH' => getenv('PATH'), 'HOME' => getenv('HOME')]);
+    $process->inheritEnvironmentVariables();
+    $process->setTimeout(300)->setIdleTimeout(300)->run();
+    $exitCode = $process->getExitCode();
+    if (0 != $exitCode) {
+      throw new \Exception("Exit code: {$exitCode}\n\n" . $process->getErrorOutput() . "\n\n" . $process->getOutput());
+    }
+    return [$process->getOutput(), $process->getErrorOutput()];
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Fixtures.php b/core/tests/Drupal/Tests/Component/Scaffold/Fixtures.php
new file mode 100644
index 0000000000..802d9a3aeb
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Fixtures.php
@@ -0,0 +1,329 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold;
+
+use Composer\Composer;
+use Composer\Console\Application;
+use Composer\Factory;
+use Composer\IO\BufferIO;
+use Composer\Util\Filesystem;
+use Drupal\Component\Scaffold\Handler;
+use Drupal\Component\Scaffold\Interpolator;
+use Drupal\Component\Scaffold\Operations\AppendOp;
+use Drupal\Component\Scaffold\Operations\ReplaceOp;
+use Drupal\Component\Scaffold\ScaffoldFilePath;
+use Symfony\Component\Console\Input\StringInput;
+use Symfony\Component\Console\Output\BufferedOutput;
+
+/**
+ * Convenience class for creating fixtures.
+ */
+class Fixtures {
+  /**
+   * Directories to delete when we are done.
+   *
+   * @var string[]
+   */
+  protected $tmpDirs = [];
+  protected $io;
+  protected $composer;
+
+  /**
+   * Get an IO fixture.
+   *
+   * @return \Composer\IO\IOInterface
+   *   A Composer IOInterface to write to; output may be retrieved via Fixtures::getOutput()
+   */
+  public function io() {
+    if (!$this->io) {
+      $this->io = new BufferIO();
+    }
+    return $this->io;
+  }
+
+  /**
+   * Get the Composer object.
+   *
+   * @return \Composer\Composer
+   *   The main Composer object, needed by the scaffold Handler, etc.
+   */
+  public function getComposer() {
+    if (!$this->composer) {
+      $this->composer = Factory::create($this->io(), NULL, TRUE);
+    }
+    return $this->composer;
+  }
+
+  /**
+   * Get the output from our io() fixture.
+   *
+   * @return string
+   *   Output captured from tests that write to Fixtures::io().
+   */
+  public function getOutput() {
+    return $this->io()->getOutput();
+  }
+
+  /**
+   * Return the path to this project so that it may be injected into composer.json files.
+   *
+   * @return string
+   *   Path to the root of this project.
+   */
+  public function projectRoot() {
+    return realpath(__DIR__) . '/../../../../../../core/lib/Drupal/Component/Scaffold';
+  }
+
+  /**
+   * Return the path to the project fixtures.
+   *
+   * @return string
+   *   Path to project fixtures
+   */
+  public function allFixturesDir() {
+    return realpath(__DIR__ . '/fixtures');
+  }
+
+  /**
+   * Return the path to one particular project fixture.
+   *
+   * @return string
+   *   Path to project fixture
+   */
+  public function projectFixtureDir($project_name) {
+    $dir = $this->allFixturesDir() . '/' . $project_name;
+    if (!is_dir($dir)) {
+      throw new \Exception("Requested fixture project {$project_name} that does not exist.");
+    }
+    return $dir;
+  }
+
+  /**
+   * Return the path to one particular bin path.
+   *
+   * @return string
+   *   Path to project fixture
+   */
+  public function binFixtureDir($bin_name) {
+    $dir = $this->allFixturesDir() . '/scripts/' . $bin_name;
+    if (!is_dir($dir)) {
+      throw new \Exception("Requested fixture bin dir {$bin_name} that does not exist.");
+    }
+    return $dir;
+  }
+
+  /**
+   * Use in place of ScaffoldFilePath::sourcePath to get a path to a source scaffold fixture.
+   *
+   * @param string $project_name
+   *   The name of the project to fetch; $package_name is "fixtures/$project_name".
+   * @param string $source
+   *   The name of the asset; path is "assets/$source".
+   * @param string $destination
+   *   The path to the destination; only used in error messages, not needed for most tests.
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+   *   The full and relative path to the desired asset
+   */
+  public function sourcePath($project_name, $source, $destination = 'unknown') {
+    $package_name = "fixtures/{$project_name}";
+    $source_rel_path = "assets/{$source}";
+    $package_path = $this->projectFixtureDir($project_name);
+    $destination = 'unknown';
+    return ScaffoldFilePath::sourcePath($package_name, $package_path, $destination, $source_rel_path);
+  }
+
+  /**
+   * Use in place of Handler::getLocationReplacements() to obtain a 'web-root'.
+   *
+   * @return \Drupal\Component\Scaffold\Interpolator
+   *   An interpolator with location replacements, including 'web-root'.
+   */
+  public function getLocationReplacements() {
+    $destinationTmpDir = $this->mkTmpDir();
+    $interpolator = new Interpolator();
+    $interpolator->setData(['web-root' => $destinationTmpDir, 'package-name' => 'fixtures/tmp-destination']);
+    return $interpolator;
+  }
+
+  /**
+   * Use to create a ReplaceOp fixture.
+   *
+   * @param string $project_name
+   *   The name of the project to fetch; $package_name is "fixtures/$project_name".
+   * @param string $source
+   *   The name of the asset; path is "assets/$source".
+   *
+   * @return \Drupal\Component\Scaffold\Operations\ReplaceOp
+   *   A replace operation object.
+   */
+  public function replaceOp($project_name, $source) {
+    $source_path = $this->sourcePath($project_name, $source);
+    return (new ReplaceOp())->setSource($source_path);
+  }
+
+  /**
+   * Use to create an AppendOp fixture.
+   *
+   * @param string $project_name
+   *   The name of the project to fetch; $package_name is "fixtures/$project_name".
+   * @param string $source
+   *   The name of the asset; path is "assets/$source".
+   *
+   * @return \Drupal\Component\Scaffold\Operations\AppendOp
+   *   An append opperation object.
+   */
+  public function appendOp($project_name, $source) {
+    $source_path = $this->sourcePath($project_name, $source);
+    return (new AppendOp())->setAppendFile($source_path);
+  }
+
+  /**
+   * Use in place of ScaffoldFilePath::destinationPath to get a destination path in a tmp dir.
+   *
+   * @param string $destination
+   *   Destination path; should be in the form '[web-root]/robots.txt', where
+   *   '[web-root]' is always literally '[web-root]', with any arbitrarily
+   *   desired filename following.
+   * @param \Drupal\Component\Scaffold\Interpolator $interpolator
+   *   Location replacements. Obtain via Fixtures::getLocationReplacements()
+   *   when creating multiple scaffold destinations.
+   * @param string $package_name
+   *   The name of the fixture package that this path came from. Optional;
+   *   taken from interpolator if not provided.
+   *
+   * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+   *   A destination scaffold file backed by temporary storage.
+   */
+  public function destinationPath($destination, Interpolator $interpolator = NULL, $package_name = NULL) {
+    $interpolator = $interpolator ?: $this->getLocationReplacements();
+    $package_name = $package_name ?: $interpolator->interpolate('[package-name]');
+    return ScaffoldFilePath::destinationPath($package_name, $destination, $interpolator);
+  }
+
+  /**
+   * Generate a path to a temporary location, but do not create the directory.
+   *
+   * @param string $extraSalt
+   *   Extra characters to throw into the md5 to add to name.
+   *
+   * @return string
+   *   Path to temporary directory
+   */
+  public function tmpDir($extraSalt = '') {
+    $tmpDir = sys_get_temp_dir() . '/composer-scaffold-test-' . md5($extraSalt . microtime());
+    $this->tmpDirs[] = $tmpDir;
+    return $tmpDir;
+  }
+
+  /**
+   * Create a temporary directory.
+   *
+   * @param string $extraSalt
+   *   Extra characters to throw into the md5 to add to name.
+   *
+   * @return string
+   *   Path to temporary directory
+   */
+  public function mkTmpDir($extraSalt = '') {
+    $tmpDir = $this->tmpDir($extraSalt);
+    $filesystem = new Filesystem();
+    $filesystem->ensureDirectoryExists($tmpDir);
+    return $tmpDir;
+  }
+
+  /**
+   * Call 'tearDown' in any test that copies fixtures to transient locations.
+   */
+  public function tearDown() {
+    // Remove any temporary directories that were created.
+    $filesystem = new Filesystem();
+    foreach ($this->tmpDirs as $dir) {
+      $filesystem->remove($dir);
+    }
+    // Clear out variables from the previous pass.
+    $this->tmpDirs = [];
+    $this->fixturesDir = NULL;
+    $this->io = NULL;
+  }
+
+  /**
+   * Create a temporary copy of all of the fixtures projects into a temp dir.
+   *
+   * The fixtures remain dirty if they already exist. Individual tests should
+   * first delete any fixture directory that needs to remain pristine. Since
+   * all temporary directories are removed in tearDown, this is only an issue
+   * when a) the FIXTURE_DIR environment variable has been set, or b) tests
+   * are calling cloneFixtureProjects more than once per test method.
+   *
+   * @param string $fixturesDir
+   *   The directory to place fixtures in.
+   * @param array $replacements
+   *   Key : value mappings for placeholders to replace in composer.json templates.
+   */
+  public function cloneFixtureProjects($fixturesDir, array $replacements = []) {
+    $filesystem = new Filesystem();
+    $replacements += ['SYMLINK' => 'true'];
+    $interpolator = new Interpolator('__', '__', TRUE);
+    $interpolator->setData($replacements);
+    $filesystem->copy($this->allFixturesDir(), $fixturesDir);
+    $composer_json_templates = glob($fixturesDir . "/*/composer.json.tmpl");
+    foreach ($composer_json_templates as $composer_json_tmpl) {
+      // Inject replacements into composer.json.
+      if (file_exists($composer_json_tmpl)) {
+        $composer_json_contents = file_get_contents($composer_json_tmpl);
+        $composer_json_contents = $interpolator->interpolate($composer_json_contents, [], FALSE);
+        file_put_contents(dirname($composer_json_tmpl) . "/composer.json", $composer_json_contents);
+        @unlink($composer_json_tmpl);
+      }
+    }
+  }
+
+  /**
+   * Run the scaffold operation.
+   *
+   * This is equivalent to running `composer composer-scaffold`, but we
+   * do the equivalent operation by instantiating a Handler object in order
+   * to continue running in the same process, so that coverage may be
+   * calculated for the code executed by these tests.
+   */
+  public function runScaffold($cwd) {
+    chdir($cwd);
+    $handler = new Handler($this->getComposer(), $this->io());
+    $handler->scaffold();
+    return $this->getOutput();
+  }
+
+  /**
+   * Runs a `composer` command.
+   *
+   * @param string $cmd
+   *   The Composer command to execute (escaped as required)
+   * @param string $cwd
+   *   The current working directory to run the command from.
+   * @param int $expectedExitCode
+   *   The expected exit code; will throw if a different exit code is returned.
+   *
+   * @return array
+   *   Standard output and standard error from the command
+   */
+  public function runComposer($cmd, $cwd, $expectedExitCode = 0) {
+    chdir($cwd);
+    $input = new StringInput($cmd);
+    $output = new BufferedOutput();
+    $application = new Application();
+    $application->setAutoExit(FALSE);
+    try {
+      $exitCode = $application->run($input, $output);
+    }
+    catch (\Exception $e) {
+      print "Exception: " . $e->getMessage() . "\n";
+    }
+    if ($exitCode != $expectedExitCode) {
+      print "Command '{$cmd}' - Expected exit code: {$expectedExitCode}, actual exit code: {$exitCode}\n";
+    }
+    $output = $output->fetch();
+    return $output;
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Functional/ComposerHookTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ComposerHookTest.php
new file mode 100644
index 0000000000..12fd9f1166
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ComposerHookTest.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Functional;
+
+use Composer\Util\Filesystem;
+use Drupal\Tests\Component\Scaffold\AssertUtilsTrait;
+use Drupal\Tests\Component\Scaffold\ExecTrait;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Tests Composer Hooks that run scaffold operations.
+ *
+ * The purpose of this test file is to exercise all of the different Composer
+ * commands that invoke scaffold operations, and ensure that files are
+ * scaffolded when they should be.
+ *
+ * Note that this test file uses `exec` to run Composer for a pure functional
+ * test. Other functional test files invoke Composer commands directly via the
+ * Composer Application object, in order to get more accurate test coverage
+ * information.
+ *
+ * @group Scaffold
+ */
+class ComposerHookTest extends TestCase {
+  use ExecTrait;
+  use AssertUtilsTrait;
+  /**
+   * The root of this project.
+   *
+   * Used to substitute this project's base directory into composer.json files
+   * so Composer can find it.
+   *
+   * @var string
+   */
+  protected $projectRoot;
+  /**
+   * Directory to perform the tests in.
+   *
+   * @var string
+   */
+  protected $fixturesDir;
+  /**
+   * The Symfony FileSystem component.
+   *
+   * @var \Symfony\Component\Filesystem\Filesystem
+   */
+  protected $fileSystem;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->fileSystem = new Filesystem();
+    $this->fixtures = new Fixtures();
+    $this->projectRoot = $this->fixtures->projectRoot();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown() {
+    // Remove any temporary directories et. al. that were created.
+    $this->fixtures->tearDown();
+  }
+
+  /**
+   * Test to see if scaffold operation runs at the correct times.
+   */
+  public function testComposerHooks() {
+    $this->fixturesDir = $this->fixtures->tmpDir($this->getName());
+    $is_link = FALSE;
+    $replacements = ['SYMLINK' => $is_link ? 'true' : 'false', 'PROJECT_ROOT' => $this->projectRoot];
+    $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
+    $topLevelProjectDir = 'composer-hooks-fixture';
+    $sut = $this->fixturesDir . '/' . $topLevelProjectDir;
+    // First test: run composer install. This is the same as composer update
+    // since there is no lock file. Ensure that scaffold operation ran.
+    $this->execComposer("install --no-ansi", $sut);
+    $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', $is_link, '#Test version of default.settings.php from drupal/core#');
+    // Run composer required to add in the scaffold-override-fixture. This
+    // project is "allowed" in our main fixture project, but not required.
+    // We expect that requiring this library should re-scaffold, resulting
+    // in a changed default.settings.php file.
+    list($stdout, $stderr) = $this->execComposer("require --no-ansi --no-interaction fixtures/scaffold-override-fixture:dev-master", $sut);
+    $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', $is_link, '#scaffolded from the scaffold-override-fixture#');
+    // Make sure that the appropriate notice informing us that scaffolding
+    // is allowed was printed.
+    $this->assertContains('Package fixtures/scaffold-override-fixture has scaffold operations, and is already allowed in the root-level composer.json file.', $stdout);
+    // Delete one scaffold file, just for test purposes, then run
+    // 'composer update' and see if the scaffold file is replaced.
+    @unlink($sut . '/sites/default/default.settings.php');
+    $this->execComposer("update --no-ansi", $sut);
+    $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', $is_link, '#scaffolded from the scaffold-override-fixture#');
+    // Delete the same test scaffold file again, then run
+    // 'composer composer:scaffold' and see if the scaffold file is replaced.
+    @unlink($sut . '/sites/default/default.settings.php');
+    $this->execComposer("composer:scaffold --no-ansi", $sut);
+    $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', $is_link, '#scaffolded from the scaffold-override-fixture#');
+    // Run 'composer create-project' to create a new test project called
+    // 'create-project-test', which is a copy of 'fixtures/drupal-drupal'.
+    $packages = $this->fixturesDir . '/packages.json';
+    $sut = $this->fixturesDir . '/create-project-test';
+    $filesystem = new Filesystem();
+    $filesystem->remove($sut);
+    list($stdout, $stderr) = $this->execComposer("create-project --repository=packages.json fixtures/drupal-drupal {$sut}", $this->fixturesDir, ['COMPOSER_MIRROR_PATH_REPOS' => 1]);
+    $this->assertDirectoryExists($sut);
+    $this->assertContains('Scaffolding files for fixtures/drupal-drupal', $stdout);
+    $this->assertScaffoldedFile($sut . '/index.php', FALSE, '#Test version of index.php from drupal/core#');
+    $topLevelProjectDir = 'composer-hooks-nothing-allowed-fixture';
+    $sut = $this->fixturesDir . '/' . $topLevelProjectDir;
+    // Run composer install on an empty project.
+    $this->execComposer("install --no-ansi", $sut);
+    // Require a project that is not allowed to scaffold and confirm that we
+    // get a warning, and it does not scaffold.
+    list($stdout, $stderr) = $this->execComposer("require --no-ansi --no-interaction fixtures/scaffold-override-fixture:dev-master", $sut);
+    $this->assertFileNotExists($sut . '/sites/default/default.settings.php');
+    $this->assertContains('Package fixtures/scaffold-override-fixture has scaffold operations, but it is not allowed in the root-level composer.json file.', $stdout);
+  }
+
+  /**
+   * Runs a `composer` command.
+   *
+   * @param string $cmd
+   *   The Composer command to execute (escaped as required)
+   * @param string $cwd
+   *   The current working directory to run the command from.
+   * @param array $env
+   *   Environment variables to define for the subprocess.
+   *
+   * @return array
+   *   Standard output and standard error from the command
+   */
+  protected function execComposer($cmd, $cwd, array $env = []) {
+    return $this->mustExec("composer {$cmd}", $cwd, $env);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Functional/ManageGitIgnoreTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ManageGitIgnoreTest.php
new file mode 100644
index 0000000000..e096b539bf
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ManageGitIgnoreTest.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Functional;
+
+use Composer\Util\Filesystem;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use Drupal\Tests\Component\Scaffold\AssertUtilsTrait;
+use Drupal\Tests\Component\Scaffold\ExecTrait;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Tests to see whether .gitignore files are correctly managed.
+ *
+ * The purpose of this test file is to run a scaffold operation and
+ * confirm that the files that were scaffolded are added to the
+ * repository's .gitignore file.
+ *
+ * @group Scaffold
+ */
+class ManageGitIgnoreTest extends TestCase {
+  use ExecTrait;
+  use AssertUtilsTrait;
+  /**
+   * The root of this project.
+   *
+   * Used to substitute this project's base directory into composer.json files
+   * so Composer can find it.
+   *
+   * @var string
+   */
+  protected $projectRoot;
+  /**
+   * Directory to perform the tests in.
+   *
+   * @var string
+   */
+  protected $fixturesDir;
+  /**
+   * The Symfony FileSystem component.
+   *
+   * @var \Symfony\Component\Filesystem\Filesystem
+   */
+  protected $fileSystem;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->fileSystem = new Filesystem();
+    $this->fixtures = new Fixtures();
+    $this->projectRoot = $this->fixtures->projectRoot();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown() {
+    // Remove any temporary directories et. al. that were created.
+    $this->fixtures->tearDown();
+  }
+
+  /**
+   * Create a system-under-test and initialize a git repository for it.
+   */
+  protected function createSutWithGit($topLevelProjectDir) {
+    $is_link = FALSE;
+    $this->fixturesDir = $this->fixtures->tmpDir($this->getName());
+    $sut = $this->fixturesDir . '/' . $topLevelProjectDir;
+    $replacements = ['SYMLINK' => $is_link ? 'true' : 'false', 'PROJECT_ROOT' => $this->projectRoot];
+    $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
+    // .gitignore files will not be managed unless there is a git repository.
+    $this->mustExec('git init', $sut);
+    // Add some user info so git does not complain.
+    $this->mustExec('git config user.email "test@example.com"', $sut);
+    $this->mustExec('git config user.name "Test User"', $sut);
+    $this->mustExec('git add .', $sut);
+    $this->mustExec('git commit -m "Initial commit."', $sut);
+    // Run composer install, but supress scaffolding.
+    $this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
+    return $sut;
+  }
+
+  /**
+   * Test to see if the scaffold operation correctly manages the .gitignore file.
+   */
+  public function testManageGitIgnore() {
+    // Note that the drupal-composer-drupal-project fixture does not
+    // have any configuration settings related to .gitignore management.
+    $sut = $this->createSutWithGit('drupal-composer-drupal-project');
+    $this->assertFileNotExists($sut . '/docroot/index.php');
+    $this->assertFileNotExists($sut . '/docroot/sites/.gitignore');
+    // Run the scaffold command.
+    $this->fixtures->runScaffold($sut);
+    $this->assertFileExists($sut . '/docroot/index.php');
+    $expected = <<<EOT
+build
+.csslintrc
+.editorconfig
+.eslintignore
+.eslintrc.json
+.gitattributes
+.ht.router.php
+autoload.php
+index.php
+robots.txt
+update.php
+web.config
+EOT;
+    // At this point we should have a .gitignore file, because although we
+    // did not explicitly ask for .gitignore tracking, the vendor directory
+    // is not tracked, so the default in that instance is to manage .gitignore files.
+    $this->assertScaffoldedFile($sut . '/docroot/.gitignore', FALSE, '#' . $expected . '#msi');
+    $this->assertScaffoldedFile($sut . '/docroot/sites/.gitignore', FALSE, '#example.settings.local.php#');
+    $this->assertScaffoldedFile($sut . '/docroot/sites/default/.gitignore', FALSE, '#default.services.yml#');
+    $expected = <<<EOT
+M docroot/.gitignore
+?? docroot/sites/.gitignore
+?? docroot/sites/default/.gitignore
+EOT;
+    // Check to see whether there are any untracked files. We expect that
+    // only the .gitignore files themselves should be untracked.
+    list($stdout, $stderr) = $this->mustExec('git status --porcelain', $sut);
+    $this->assertEquals(trim($expected), trim($stdout));
+  }
+
+  /**
+   * Test to see if scaffold operation does not manage the .gitignore file when disabled.
+   */
+  public function testUnmanagedGitIgnoreWhenDisabled() {
+    // Note that the drupal-drupal fixture has a configuration setting
+    // `"gitignore": false,` which disables .gitignore file handling.
+    $sut = $this->createSutWithGit('drupal-drupal');
+    $this->assertFileNotExists($sut . '/docroot/index.php');
+    // Run the scaffold command.
+    $this->fixtures->runScaffold($sut);
+    $this->assertFileExists($sut . '/index.php');
+    $this->assertFileNotExists($sut . '/.gitignore');
+    $this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
+  }
+
+  /**
+   * Test to see if the scaffold operation disables .gitignore management when git not present.
+   *
+   * The scaffold operation should still succeed if there is no 'git' executable.
+   */
+  public function testUnmanagedGitIgnoreWhenGitNotAvailable() {
+    // Note that the drupal-composer-drupal-project fixture does not
+    // have any configuration settings related to .gitignore management.
+    $sut = $this->createSutWithGit('drupal-composer-drupal-project');
+    $this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
+    $this->assertFileNotExists($sut . '/docroot/index.php');
+    $this->assertFileNotExists($sut . '/docroot/sites/.gitignore');
+    // Confirm that 'git' is available (n.b. if it were not, createSutWithGit() would fail).
+    exec('git --help', $output, $status);
+    $this->assertEquals(0, $status);
+    // Modify our $PATH so that it begins with a path that contains an executable
+    // script named 'git' that always exits with 127, as if git were not found.
+    // Note that we run our tests using process isolation, so we do not need to
+    // restore the PATH when we are done.
+    $unavailableGitPath = $this->fixtures->binFixtureDir('disable-git-bin');
+    putenv('PATH=' . $unavailableGitPath . ':' . getenv('PATH'));
+    // Confirm that 'git' is no longer available.
+    exec('git --help', $output, $status);
+    $this->assertEquals(127, $status);
+    // Run the scaffold command.
+    exec('composer composer:scaffold', $output, $status);
+    $this->assertFileExists($sut . '/docroot/index.php');
+    $this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Functional/ScaffoldTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ScaffoldTest.php
new file mode 100644
index 0000000000..f9c9e80fdf
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ScaffoldTest.php
@@ -0,0 +1,301 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Functional;
+
+use Composer\Util\Filesystem;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use Drupal\Tests\Component\Scaffold\AssertUtilsTrait;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Tests Composer Scaffold.
+ *
+ * The purpose of this test file is to exercise all of the different kinds of
+ * scaffold operations: copy, symlinks, skips and so on.
+ *
+ * @group Scaffold
+ */
+class ScaffoldTest extends TestCase {
+  use AssertUtilsTrait;
+  const FIXTURE_DIR = 'SCAFFOLD_FIXTURE_DIR';
+  /**
+   * The root of this project.
+   *
+   * Used to substitute this project's base directory into composer.json files
+   * so Composer can find it.
+   *
+   * @var string
+   */
+  protected $projectRoot;
+  /**
+   * Directory to perform the tests in.
+   *
+   * @var string
+   */
+  protected $fixturesDir;
+  /**
+   * The file path to the system under test.
+   *
+   * @var string
+   */
+  protected $sut;
+  /**
+   * The Symfony FileSystem component.
+   *
+   * @var \Symfony\Component\Filesystem\Filesystem
+   */
+  protected $fileSystem;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->fileSystem = new Filesystem();
+    $this->fixtures = new Fixtures();
+    $this->projectRoot = $this->fixtures->projectRoot();
+    $this->fixturesDir = getenv(self::FIXTURE_DIR);
+    if (!$this->fixturesDir) {
+      $this->fixturesDir = $this->fixtures->tmpDir($this->getName());
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown() {
+    // Remove any temporary directories et. al. that were created.
+    $this->fixtures->tearDown();
+  }
+
+  /**
+   * Create the System-Under-Test.
+   */
+  protected function createSut($topLevelProjectDir, $replacements = []) {
+    $this->sut = $this->fixturesDir . '/' . $topLevelProjectDir;
+    // Erase just our sut, to ensure it is clean. Recopy all of the fixtures.
+    $this->fileSystem->remove($this->sut);
+    $replacements += ['PROJECT_ROOT' => $this->projectRoot];
+    $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
+    return $this->sut;
+  }
+
+  /**
+   * Data provider for testComposerInstallScaffold and testScaffoldCommand.
+   */
+  public function scaffoldFixturesWithErrorConditionsTestValues() {
+    return [
+      [
+        'drupal-drupal-missing-scaffold-file',
+        'Scaffold file assets/missing-robots-default.txt not found in package fixtures/drupal-drupal-missing-scaffold-file.',
+        TRUE,
+      ],
+    ];
+  }
+
+  /**
+   * Tests that scaffold files throw when they have bad values.
+   *
+   * @dataProvider scaffoldFixturesWithErrorConditionsTestValues
+   */
+  public function testScaffoldFixturesWithErrorConditions($topLevelProjectDir, $expectedExceptionMessage, $is_link) {
+    $sut = $this->createSut($topLevelProjectDir, ['SYMLINK' => $is_link ? 'true' : 'false']);
+    // Run composer install to get the dependencies we need to test.
+    $this->fixtures->runComposer("install --no-ansi --no-scripts", $this->sut);
+    // Test scaffold. Expect an error.
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage($expectedExceptionMessage);
+    $this->fixtures->runScaffold($sut);
+  }
+
+  /**
+   * Data provider for testComposerInstallScaffold and testScaffoldCommand.
+   */
+  public function scaffoldTestValues() {
+    return [
+      [
+        'drupal-composer-drupal-project',
+        'assertDrupalProjectSutWasScaffolded', TRUE,
+      ],
+      [
+        'drupal-drupal',
+        'assertDrupalDrupalSutWasScaffolded',
+        FALSE,
+      ],
+      [
+        'drupal-drupal-test-overwrite',
+        'assertDrupalDrupalFileWasReplaced',
+        FALSE,
+      ],
+      [
+        'drupal-drupal-test-append',
+        'assertDrupalDrupalFileWasAppended',
+        FALSE,
+      ],
+      [
+        'drupal-drupal-test-append',
+        'assertDrupalDrupalFileWasAppended',
+        TRUE,
+      ],
+    ];
+  }
+
+  /**
+   * Tests that scaffold files are correctly moved.
+   *
+   * @dataProvider scaffoldTestValues
+   */
+  public function testScaffold($topLevelProjectDir, $scaffoldAssertions, $is_link) {
+    $sut = $this->createSut($topLevelProjectDir, ['SYMLINK' => $is_link ? 'true' : 'false']);
+    // Run composer install to get the dependencies we need to test.
+    $this->fixtures->runComposer("install --no-ansi --no-scripts", $this->sut);
+    // Test composer:scaffold.
+    $scaffoldOutput = $this->fixtures->runScaffold($sut);
+    // @todo We could assert that $scaffoldOutput must contain some expected text
+    call_user_func([$this, $scaffoldAssertions], $sut, $is_link, $topLevelProjectDir);
+  }
+
+  /**
+   * Try to scaffold a project that does not scaffold anything.
+   */
+  public function testEmptyProject() {
+    $topLevelProjectDir = 'empty-fixture';
+    $sut = $this->createSut($topLevelProjectDir, ['SYMLINK' => 'false']);
+    // Run composer install to get the dependencies we need to test.
+    $this->fixtures->runComposer("install --no-ansi --no-scripts", $this->sut);
+    // Test composer:scaffold.
+    $scaffoldOutput = $this->fixtures->runScaffold($sut);
+    $this->assertEquals('', $scaffoldOutput);
+  }
+
+  /**
+   * Try to scaffold a project that allows a project with no scaffold files.
+   */
+  public function testProjectThatScaffoldsEmptyProject() {
+    $topLevelProjectDir = 'project-allowing-empty-fixture';
+    $sut = $this->createSut($topLevelProjectDir, ['SYMLINK' => 'false']);
+    // Run composer install to get the dependencies we need to test.
+    $this->fixtures->runComposer("install --no-ansi --no-scripts", $this->sut);
+    // Test composer:scaffold.
+    $scaffoldOutput = $this->fixtures->runScaffold($sut);
+    $this->assertContains('The allowed package fixtures/empty-fixture does not provide a file mapping for Composer Scaffold', $scaffoldOutput);
+    $docroot = $sut;
+    $this->assertCommonDrupalAssetsWereScaffolded($docroot, FALSE, $topLevelProjectDir);
+  }
+
+  /**
+   * Try to scaffold a project that attempts to scaffold a file with no path.
+   */
+  public function testProjectWithEmptyScaffoldPath() {
+    $topLevelProjectDir = 'project-with-empty-scaffold-path';
+    $sut = $this->createSut($topLevelProjectDir, ['SYMLINK' => 'false']);
+    // Run composer install to get the dependencies we need to test.
+    $this->fixtures->runComposer("install --no-ansi --no-scripts", $this->sut);
+    // Test composer:scaffold.
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage('No scaffold file path given for [web-root]/my-error in package fixtures/project-with-empty-scaffold-path');
+    $this->fixtures->runScaffold($sut);
+  }
+
+  /**
+   * Try to scaffold a project that attempts to scaffold a directory.
+   */
+  public function testProjectWithIllegalDirScaffold() {
+    $topLevelProjectDir = 'project-with-illegal-dir-scaffold';
+    $sut = $this->createSut($topLevelProjectDir, ['SYMLINK' => 'false']);
+    // Run composer install to get the dependencies we need to test.
+    $this->fixtures->runComposer("install --no-ansi --no-scripts", $this->sut);
+    // Test composer:scaffold.
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage('Scaffold file assets in package fixtures/project-with-illegal-dir-scaffold is a directory; only files may be scaffolded');
+    $this->fixtures->runScaffold($sut);
+  }
+
+  /**
+   * Asserts that the drupal/assets scaffold files correct for drupal/project layout.
+   */
+  protected function assertDrupalProjectSutWasScaffolded($sut, $is_link, $project_name) {
+    $docroot = $sut . '/docroot';
+    $this->assertCommonDrupalAssetsWereScaffolded($docroot, $is_link, $project_name);
+    $this->assertDefaultSettingsFromScaffoldOverride($docroot, $is_link);
+    $this->assertHtaccessExcluded($docroot);
+  }
+
+  /**
+   * Asserts that the drupal/assets scaffold files correct for drupal/drupal layout.
+   */
+  protected function assertDrupalDrupalSutWasScaffolded($sut, $is_link, $project_name) {
+    $docroot = $sut;
+    $this->assertCommonDrupalAssetsWereScaffolded($docroot, $is_link, $project_name);
+    $this->assertDefaultSettingsFromScaffoldOverride($docroot, $is_link);
+    $this->assertHtaccessExcluded($docroot);
+  }
+
+  /**
+   * Ensure that the default settings file was overridden by the test.
+   */
+  protected function assertDefaultSettingsFromScaffoldOverride($docroot, $is_link) {
+    $this->assertScaffoldedFile($docroot . '/sites/default/default.settings.php', $is_link, '#scaffolded from the scaffold-override-fixture#');
+  }
+
+  /**
+   * Ensure that the .htaccess file was excluded by the test.
+   */
+  protected function assertHtaccessExcluded($docroot) {
+    // Ensure that the .htaccess.txt file was not written, as our
+    // top-level composer.json excludes it from the files to scaffold.
+    $this->assertFileNotExists($docroot . '/.htaccess');
+  }
+
+  /**
+   * Assert that the appropriate file was replaced.
+   *
+   * Check the drupal/drupal-based project to confirm that the expected file was
+   * replaced, and that files that were not supposed to be replaced remain
+   * unchanged.
+   */
+  protected function assertDrupalDrupalFileWasReplaced($sut, $is_link, $project_name) {
+    $docroot = $sut;
+    $this->assertScaffoldedFile($docroot . '/replace-me.txt', $is_link, '#from assets that replaces file#');
+    $this->assertScaffoldedFile($docroot . '/keep-me.txt', $is_link, '#File in drupal-drupal-test-overwrite that is not replaced#');
+    $this->assertScaffoldedFile($docroot . '/make-me.txt', $is_link, '#from assets that replaces file#');
+    $this->assertCommonDrupalAssetsWereScaffolded($docroot, $is_link, $project_name);
+    $this->assertScaffoldedFile($docroot . '/robots.txt', $is_link, "#{$project_name}#");
+  }
+
+  /**
+   * Confirm that the robots.txt file was prepended / appended as stipulated in the test.
+   */
+  protected function assertDrupalDrupalFileWasAppended($sut, $is_link, $project_name) {
+    $docroot = $sut;
+    $this->assertScaffoldedFile($docroot . '/robots.txt', FALSE, '#in drupal-drupal-test-append composer.json fixture.*This content is prepended to the top of the existing robots.txt fixture.*Test version of robots.txt from drupal/core.*This content is appended to the bottom of the existing robots.txt fixture.*in drupal-drupal-test-append composer.json fixture#ms');
+    $this->assertCommonDrupalAssetsWereScaffolded($docroot, $is_link, $project_name);
+  }
+
+  /**
+   * Assert that the scaffold files from drupal/assets are placed as we expect them to be.
+   *
+   * This tests that all assets from drupal/assets were scaffolded, save
+   * for .htaccess, robots.txt and default.settings.php, which are scaffolded
+   * in different ways in different tests.
+   */
+  protected function assertCommonDrupalAssetsWereScaffolded($docroot, $is_link, $project_name) {
+    $from_project = "#scaffolded from \"file-mappings\" in {$project_name} composer.json fixture#";
+    $from_core = '#from drupal/core#';
+    // Ensure that the autoload.php file was written.
+    $this->assertFileExists($docroot . '/autoload.php');
+    // Assert other scaffold files are written in the correct locations.
+    $this->assertScaffoldedFile($docroot . '/.csslintrc', $is_link, $from_core);
+    $this->assertScaffoldedFile($docroot . '/.editorconfig', $is_link, $from_core);
+    $this->assertScaffoldedFile($docroot . '/.eslintignore', $is_link, $from_core);
+    $this->assertScaffoldedFile($docroot . '/.eslintrc.json', $is_link, $from_core);
+    $this->assertScaffoldedFile($docroot . '/.gitattributes', $is_link, $from_core);
+    $this->assertScaffoldedFile($docroot . '/.ht.router.php', $is_link, $from_core);
+    $this->assertScaffoldedFile($docroot . '/sites/default/default.services.yml', $is_link, $from_core);
+    $this->assertScaffoldedFile($docroot . '/sites/example.settings.local.php', $is_link, $from_core);
+    $this->assertScaffoldedFile($docroot . '/sites/example.sites.php', $is_link, $from_core);
+    $this->assertScaffoldedFile($docroot . '/index.php', $is_link, $from_core);
+    $this->assertScaffoldedFile($docroot . '/update.php', $is_link, $from_core);
+    $this->assertScaffoldedFile($docroot . '/web.config', $is_link, $from_core);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/AppendOpTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/AppendOpTest.php
new file mode 100644
index 0000000000..339daa0ae1
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/AppendOpTest.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Integration;
+
+use Drupal\Component\Scaffold\Operations\AppendOp;
+use Drupal\Component\Scaffold\Operations\ReplaceOp;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Component\Scaffold\Operations\AppendOp
+ *
+ * @group Scaffold
+ */
+class AppendOpTest extends TestCase {
+
+  /**
+   * @covers ::process
+   */
+  public function testProcess() {
+    $fixtures = new Fixtures();
+    $destination = $fixtures->destinationPath('[web-root]/robots.txt');
+    $source = $fixtures->sourcePath('drupal-assets-fixture', 'robots.txt');
+    $options = ScaffoldOptions::defaultOptions();
+    $originalOp = new ReplaceOp();
+    $originalOp->setSource($source);
+    $originalOp->setOverwrite(TRUE);
+    $prepend = $fixtures->sourcePath('drupal-drupal-test-append', 'prepend-to-robots.txt');
+    $append = $fixtures->sourcePath('drupal-drupal-test-append', 'append-to-robots.txt');
+    $sut = new AppendOp();
+    $sut->setOriginalOp($originalOp);
+    $sut->setPrependFile($prepend);
+    $sut->setAppendFile($append);
+    // Assert that there is no target file before we run our test.
+    $this->assertFileNotExists($destination->fullPath());
+    // Test the system under test.
+    $sut->process($destination, $fixtures->io(), $options);
+    // Assert that the target file was created.
+    $this->assertFileExists($destination->fullPath());
+    // Assert the target contained the contents from the correct scaffold files.
+    $contents = trim(file_get_contents($destination->fullPath()));
+    $expected = <<<EOT
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
+# This content is prepended to the top of the existing robots.txt fixture.
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+# Test version of robots.txt from drupal/core.
+
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
+EOT;
+    $this->assertEquals(trim($expected), $contents);
+    // Confirm that expected output was written to our io fixture.
+    $output = $fixtures->getOutput();
+    $this->assertContains('Copy [web-root]/robots.txt from assets/robots.txt', $output);
+    $this->assertContains('Prepend to [web-root]/robots.txt from assets/prepend-to-robots.txt', $output);
+    $this->assertContains('Append to [web-root]/robots.txt from assets/append-to-robots.txt', $output);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/OperationCollectionTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/OperationCollectionTest.php
new file mode 100644
index 0000000000..9ba1f92222
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/OperationCollectionTest.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Integration;
+
+use PHPUnit\Framework\TestCase;
+use Drupal\Component\Scaffold\ScaffoldFileInfo;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use Drupal\Component\Scaffold\Operations\SkipOp;
+use Drupal\Component\Scaffold\Operations\OperationCollection;
+
+/**
+ * @coversDefaultClass \Drupal\Component\Scaffold\Operations\OperationCollection
+ *
+ * @group Scaffold
+ */
+class OperationCollectionTest extends TestCase {
+
+  /**
+   * @covers ::coalateScaffoldFiles
+   */
+  public function testCoalateScaffoldFiles() {
+    $fixtures = new Fixtures();
+    $locationReplacements = $fixtures->getLocationReplacements();
+    $file_mappings = [
+      'fixtures/drupal-assets-fixture' => [
+        '[web-root]/index.php' => $fixtures->replaceOp('drupal-assets-fixture', 'index.php'),
+        '[web-root]/.htaccess' => $fixtures->replaceOp('drupal-assets-fixture', '.htaccess'),
+        '[web-root]/robots.txt' => $fixtures->replaceOp('drupal-assets-fixture', 'robots.txt'),
+        '[web-root]/sites/default/default.services.yml' => $fixtures->replaceOp('drupal-assets-fixture', 'default.services.yml'),
+      ],
+      'fixtures/drupal-profile' => [
+        '[web-root]/sites/default/default.services.yml' => $fixtures->replaceOp('drupal-profile', 'profile.default.services.yml'),
+      ],
+      'fixtures/drupal-drupal' => [
+        '[web-root]/.htaccess' => new SkipOp(),
+        '[web-root]/robots.txt' => $fixtures->appendOp('drupal-drupal-test-append', 'append-to-robots.txt'),
+      ],
+    ];
+    $sut = new OperationCollection($fixtures->io());
+    // Test the system under test.
+    $sut->coalateScaffoldFiles($file_mappings, $locationReplacements);
+    $resolved_file_mappings = $sut->fileMappings();
+    $scaffold_list = $sut->scaffoldList();
+    // Confirm that the keys of the output are the same as the keys of the input.
+    $this->assertEquals(array_keys($file_mappings), array_keys($resolved_file_mappings));
+    // Also assert that we have the right ScaffoldFileInfo objects in the destination.
+    $this->assertResolvedToSameOp('fixtures/drupal-assets-fixture', '[web-root]/index.php', $file_mappings, $scaffold_list, $resolved_file_mappings);
+    $this->assertResolvedToSameOp('fixtures/drupal-profile', '[web-root]/sites/default/default.services.yml', $file_mappings, $scaffold_list, $resolved_file_mappings);
+    $this->assertResolvedToSameOp('fixtures/drupal-drupal', '[web-root]/robots.txt', $file_mappings, $scaffold_list, $resolved_file_mappings);
+    // Assert that the files below have been overridden.
+    $this->assertOverridden('fixtures/drupal-assets-fixture', '[web-root]/.htaccess', $scaffold_list, $resolved_file_mappings);
+    $this->assertOverridden('fixtures/drupal-assets-fixture', '[web-root]/robots.txt', $scaffold_list, $resolved_file_mappings);
+  }
+
+  /**
+   * Check to see if a given file was not overridden.
+   *
+   * The package name in the scaffold list for the provided destination should
+   * match the package name from the specified project.
+   */
+  protected function assertResolvedToSameOp($project, $dest, $file_mappings, $scaffold_list, $resolved_file_mappings) {
+    $resolved_file_info = $resolved_file_mappings[$project][$dest];
+    $this->assertEquals(get_class($resolved_file_info), ScaffoldFileInfo::class);
+    $resolved_scaffold_op = $resolved_file_info->op();
+    $this->assertEquals(get_class($file_mappings[$project][$dest]), get_class($resolved_scaffold_op));
+    $this->assertEquals($file_mappings[$project][$dest], $resolved_scaffold_op);
+    $this->assertEquals($project, $scaffold_list[$dest]->packageName());
+  }
+
+  /**
+   * Check if a given file was overridden.
+   *
+   * Assert that the file in the scaffold list at the specified destination
+   * comes from a different package than the one in the file info.
+   */
+  protected function assertOverridden($project, $dest, $scaffold_list, $resolved_file_mappings) {
+    $resolved_file_info = $resolved_file_mappings[$project][$dest];
+    $this->assertEquals(get_class($resolved_file_info), ScaffoldFileInfo::class);
+    $this->assertNotEquals($project, $scaffold_list[$dest]->packageName());
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/ReplaceOpTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/ReplaceOpTest.php
new file mode 100644
index 0000000000..492b67ef20
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/ReplaceOpTest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Integration;
+
+use Drupal\Component\Scaffold\Operations\ReplaceOp;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Component\Scaffold\Operations\ReplaceOp
+ *
+ * @group Scaffold
+ */
+class ReplaceOpTest extends TestCase {
+
+  /**
+   * @covers ::process
+   */
+  public function testProcess() {
+    $fixtures = new Fixtures();
+    $destination = $fixtures->destinationPath('[web-root]/robots.txt');
+    $source = $fixtures->sourcePath('drupal-assets-fixture', 'robots.txt');
+    $options = ScaffoldOptions::defaultOptions();
+    $sut = new ReplaceOp();
+    $sut->setSource($source);
+    $sut->setOverwrite(TRUE);
+    // Assert that there is no target file before we run our test.
+    $this->assertFileNotExists($destination->fullPath());
+    // Test the system under test.
+    $sut->process($destination, $fixtures->io(), $options);
+    // Assert that the target file was created.
+    $this->assertFileExists($destination->fullPath());
+    // Assert the target contained the contents from the correct scaffold file.
+    $contents = trim(file_get_contents($destination->fullPath()));
+    $this->assertEquals('# Test version of robots.txt from drupal/core.', $contents);
+    // Confirm that expected output was written to our io fixture.
+    $output = $fixtures->getOutput();
+    $this->assertContains('Copy [web-root]/robots.txt from assets/robots.txt', $output);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/SkipOpTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/SkipOpTest.php
new file mode 100644
index 0000000000..fb600bd935
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/SkipOpTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Integration;
+
+use Drupal\Component\Scaffold\Operations\SkipOp;
+use Drupal\Component\Scaffold\ScaffoldOptions;
+use Drupal\Tests\Component\Scaffold\Fixtures;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Component\Scaffold\Operations\SkipOp
+ *
+ * @group Scaffold
+ */
+class SkipOpTest extends TestCase {
+
+  /**
+   * @covers ::process
+   */
+  public function testProcess() {
+    $fixtures = new Fixtures();
+    $destination = $fixtures->destinationPath('[web-root]/robots.txt');
+    $source = $fixtures->sourcePath('drupal-assets-fixture', 'robots.txt');
+    $options = ScaffoldOptions::defaultOptions();
+    $sut = new SkipOp();
+    // Assert that there is no target file before we run our test.
+    $this->assertFileNotExists($destination->fullPath());
+    // Test the system under test.
+    $sut->process($destination, $fixtures->io(), $options);
+    // Assert that the target file was not created.
+    $this->assertFileNotExists($destination->fullPath());
+    // Confirm that expected output was written to our io fixture.
+    $output = $fixtures->getOutput();
+    $this->assertContains('Skip [web-root]/robots.txt: disabled', $output);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Unit/HandlerTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Unit/HandlerTest.php
new file mode 100644
index 0000000000..881ddb1431
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Unit/HandlerTest.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\Tests\Component\Scaffold\Unit;
+
+use Composer\Composer;
+use Composer\IO\IOInterface;
+use Composer\Package\PackageInterface;
+use Drupal\Component\Scaffold\Handler;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Component\Scaffold\Handler
+ *
+ * @group Scaffold
+ */
+class HandlerTest extends TestCase {
+  /**
+   * The Composer service.
+   *
+   * @var \Composer\IO\IOInterface
+   */
+  protected $composer;
+  /**
+   * The Composer IO service.
+   *
+   * @var \Composer\IO\IOInterface
+   */
+  protected $io;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->composer = $this->prophesize(Composer::class);
+    $this->io = $this->prophesize(IOInterface::class);
+  }
+
+  /**
+   * @covers ::getWebRoot
+   */
+  public function testGetWebRoot() {
+    $expected = './build/docroot';
+    $extra = ['composer-scaffold' => ['locations' => ['web-root' => $expected]]];
+    $package = $this->prophesize(PackageInterface::class);
+    $package->getExtra()->willReturn($extra);
+    $this->composer->getPackage()->willReturn($package->reveal());
+    $fixture = new Handler($this->composer->reveal(), $this->io->reveal());
+    $this->assertSame($expected, $fixture->getWebRoot());
+    // Verify correct errors.
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage('The extra.composer-scaffold.location.web-root is not set in composer.json.');
+    $extra = ['allowed-packages' => ['foo/bar']];
+    $package->getExtra()->willReturn($extra);
+    $this->composer->getPackage()->willReturn($package->reveal());
+    $fixture = new Handler($this->composer->reveal(), $this->io->reveal());
+    $fixture->getWebRoot();
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/README.md
new file mode 100644
index 0000000000..e103ffba5c
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/README.md
@@ -0,0 +1,34 @@
+# Fixtures README
+
+These fixtures are automatically copied to a temporary directory during test runs. After the test run, the fixtures are automatically deleted.
+
+Set the SCAFFOLD_FIXTURE_DIR environment variable to place the fixtures in a specific location rather than a temporary directory. If this is done, then the fixtures will not be deleted after the test run. This is useful for ad-hoc testing.
+
+Example:
+
+$ SCAFFOLD_FIXTURE_DIR=$HOME/tmp/scaffold-fixtures composer unit
+$ cd $HOME/tmp/scaffold-fixtures
+$ cd drupal-drupal
+$ composer composer:scaffold
+
+Scaffolding files for fixtures/drupal-assets-fixture:
+  - Link [web-root]/.csslintrc from assets/.csslintrc
+  - Link [web-root]/.editorconfig from assets/.editorconfig
+  - Link [web-root]/.eslintignore from assets/.eslintignore
+  - Link [web-root]/.eslintrc.json from assets/.eslintrc.json
+  - Link [web-root]/.gitattributes from assets/.gitattributes
+  - Link [web-root]/.ht.router.php from assets/.ht.router.php
+  - Skip [web-root]/.htaccess: overridden in my/project
+  - Link [web-root]/sites/default/default.services.yml from assets/default.services.yml
+  - Skip [web-root]/sites/default/default.settings.php: overridden in fixtures/scaffold-override-fixture
+  - Link [web-root]/sites/example.settings.local.php from assets/example.settings.local.php
+  - Link [web-root]/sites/example.sites.php from assets/example.sites.php
+  - Link [web-root]/index.php from assets/index.php
+  - Skip [web-root]/robots.txt: overridden in my/project
+  - Link [web-root]/update.php from assets/update.php
+  - Link [web-root]/web.config from assets/web.config
+Scaffolding files for fixtures/scaffold-override-fixture:
+  - Link [web-root]/sites/default/default.settings.php from assets/override-settings.php
+Scaffolding files for my/project:
+  - Skip [web-root]/.htaccess: disabled
+  - Link [web-root]/robots.txt from assets/robots-default.txt
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/assets/robots-default.txt
new file mode 100644
index 0000000000..a26bf8912f
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in composer-hooks-fixture composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/composer.json.tmpl
new file mode 100644
index 0000000000..f08fa60383
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/composer.json.tmpl
@@ -0,0 +1,67 @@
+{
+  "name": "fixtures/drupal-drupal",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/robots-default.txt"
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/assets/robots-default.txt
new file mode 100644
index 0000000000..a26bf8912f
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in composer-hooks-fixture composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/composer.json.tmpl
new file mode 100644
index 0000000000..2acbea22f4
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/composer.json.tmpl
@@ -0,0 +1,63 @@
+{
+  "name": "fixtures/drupal-drupal",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "locations": {
+        "web-root": "./"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/robots-default.txt"
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.csslintrc b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.csslintrc
new file mode 100644
index 0000000000..f5bca65208
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.csslintrc
@@ -0,0 +1 @@
+# Test version of .csslintrc from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.editorconfig b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.editorconfig
new file mode 100644
index 0000000000..dcf0c98bbf
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.editorconfig
@@ -0,0 +1 @@
+# Test version of .editorconfig from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintignore b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintignore
new file mode 100644
index 0000000000..f0405c03ad
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintignore
@@ -0,0 +1 @@
+# Test version of .eslintignore from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintrc.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintrc.json
new file mode 100644
index 0000000000..6391fb0c6e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintrc.json
@@ -0,0 +1 @@
+// Test version of .eslintrc.json from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.gitattributes b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.gitattributes
new file mode 100644
index 0000000000..03436baebc
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.gitattributes
@@ -0,0 +1 @@
+# Test version of .gitattributes from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.ht.router.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.ht.router.php
new file mode 100644
index 0000000000..0cd34ad7ad
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.ht.router.php
@@ -0,0 +1,2 @@
+<?php
+// Test version of .ht.router.php from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.htaccess b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.htaccess
new file mode 100644
index 0000000000..4cfce848ab
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.htaccess
@@ -0,0 +1 @@
+# Test version of .htaccess from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.services.yml b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.services.yml
new file mode 100644
index 0000000000..cc773f3e79
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.services.yml
@@ -0,0 +1,4 @@
+# Test version of default.services.yml from drupal/core.
+# Add a dummy key until YamlPecl can validate an empty YAML file:
+# https://www.drupal.org/project/drupal/issues/3003300
+foo: bar
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.settings.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.settings.php
new file mode 100644
index 0000000000..82b1cb21e1
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/default.settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Test version of default.settings.php from drupal/core.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.settings.local.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.settings.local.php
new file mode 100644
index 0000000000..074efa4648
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.settings.local.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Test version of example.settings.local.php from drupal/core.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.sites.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.sites.php
new file mode 100644
index 0000000000..d0e0e01005
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/example.sites.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Test version of example.sites.php from drupal/core.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/index.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/index.php
new file mode 100644
index 0000000000..6dd96fde33
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/index.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Test version of index.php from drupal/core.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/robots.txt
new file mode 100644
index 0000000000..19d2a6b087
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/robots.txt
@@ -0,0 +1 @@
+# Test version of robots.txt from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/update.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/update.php
new file mode 100644
index 0000000000..3ccf1cb1b0
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/update.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Test version of update.php from drupal/core.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/web.config b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/web.config
new file mode 100644
index 0000000000..f3344d0a20
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/web.config
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Test version of web.config from drupal/core. -->
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/composer.json
new file mode 100644
index 0000000000..cb45ec5dc4
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/composer.json
@@ -0,0 +1,24 @@
+{
+  "name": "fixtures/drupal-assets-fixture",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/.csslintrc": "assets/.csslintrc",
+        "[web-root]/.editorconfig": "assets/.editorconfig",
+        "[web-root]/.eslintignore": "assets/.eslintignore",
+        "[web-root]/.eslintrc.json": "assets/.eslintrc.json",
+        "[web-root]/.gitattributes": "assets/.gitattributes",
+        "[web-root]/.ht.router.php": "assets/.ht.router.php",
+        "[web-root]/.htaccess": "assets/.htaccess",
+        "[web-root]/sites/default/default.services.yml": "assets/default.services.yml",
+        "[web-root]/sites/default/default.settings.php": "assets/default.settings.php",
+        "[web-root]/sites/example.settings.local.php": "assets/example.settings.local.php",
+        "[web-root]/sites/example.sites.php": "assets/example.sites.php",
+        "[web-root]/index.php": "assets/index.php",
+        "[web-root]/robots.txt": "assets/robots.txt",
+        "[web-root]/update.php": "assets/update.php",
+        "[web-root]/web.config": "assets/web.config"
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/.gitignore b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/.gitignore
new file mode 100644
index 0000000000..19982ea3fd
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/.gitignore
@@ -0,0 +1,2 @@
+composer.lock
+vendor
\ No newline at end of file
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/assets/robots-default.txt
new file mode 100644
index 0000000000..c29217ef39
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-composer-drupal-project composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/composer.json.tmpl
new file mode 100644
index 0000000000..207e3baa6c
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/composer.json.tmpl
@@ -0,0 +1,68 @@
+{
+  "name": "fixtures/drupal-composer-drupal-project",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*",
+    "fixtures/scaffold-override-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "locations": {
+        "web-root": "./docroot"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/robots-default.txt"
+      }
+    },
+    "installer-paths": {
+      "docroot/core": ["type:drupal-core"],
+      "docroot/modules/contrib/{$name}": ["type:drupal-module"],
+      "docroot/modules/custom/{$name}": ["type:drupal-custom-module"],
+      "docroot/profiles/contrib/{$name}": ["type:drupal-profile"],
+      "docroot/profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "docroot/themes/contrib/{$name}": ["type:drupal-theme"],
+      "docroot/themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "docroot/libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/.gitignore b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/.gitignore
new file mode 100644
index 0000000000..c795b054e5
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/.gitignore
@@ -0,0 +1 @@
+build
\ No newline at end of file
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/README.md
new file mode 100644
index 0000000000..7e59600739
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/README.md
@@ -0,0 +1 @@
+# README
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/sites/default/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/sites/default/README.md
new file mode 100644
index 0000000000..7e59600739
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/sites/default/README.md
@@ -0,0 +1 @@
+# README
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-core-fixture/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-core-fixture/composer.json
new file mode 100644
index 0000000000..9aa29f55bf
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-core-fixture/composer.json
@@ -0,0 +1,13 @@
+{
+  "name": "fixtures/drupal-core-fixture",
+  "require": {
+    "fixtures/drupal-assets-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-assets-fixture"
+      ]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-missing-scaffold-file/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-missing-scaffold-file/composer.json.tmpl
new file mode 100644
index 0000000000..8d9cf57293
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-missing-scaffold-file/composer.json.tmpl
@@ -0,0 +1,68 @@
+{
+  "name": "fixtures/drupal-drupal-missing-scaffold-file",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*",
+    "fixtures/scaffold-override-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/missing-robots-default.txt"
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/append-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/append-to-robots.txt
new file mode 100644
index 0000000000..8f05fc3b3a
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/append-to-robots.txt
@@ -0,0 +1,3 @@
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/prepend-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/prepend-to-robots.txt
new file mode 100644
index 0000000000..995c204a6e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/prepend-to-robots.txt
@@ -0,0 +1,3 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
+# This content is prepended to the top of the existing robots.txt fixture.
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/composer.json.tmpl
new file mode 100644
index 0000000000..9e6c579692
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/composer.json.tmpl
@@ -0,0 +1,62 @@
+{
+  "name": "fixtures/drupal-drupal-test-append",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": {
+          "prepend": "assets/prepend-to-robots.txt",
+          "append": "assets/append-to-robots.txt"
+        }
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/replacement.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/replacement.txt
new file mode 100644
index 0000000000..4e23d0c860
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/replacement.txt
@@ -0,0 +1 @@
+# File from assets that replaces file in web root.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/robots-default.txt
new file mode 100644
index 0000000000..28c7646d81
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-overwrite composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/composer.json.tmpl
new file mode 100644
index 0000000000..d16205c612
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/composer.json.tmpl
@@ -0,0 +1,80 @@
+{
+  "name": "fixtures/drupal-drupal-test-overwrite",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*",
+    "fixtures/scaffold-override-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": "assets/robots-default.txt",
+        "make-me.txt": {
+          "path": "assets/replacement.txt",
+          "overwrite": false
+        },
+        "keep-me.txt": {
+          "path": "assets/replacement.txt",
+          "overwrite": false
+        },
+        "replace-me.txt": {
+          "path": "assets/replacement.txt",
+          "overwrite": true
+        }
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/keep-me.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/keep-me.txt
new file mode 100644
index 0000000000..772a59531a
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/keep-me.txt
@@ -0,0 +1 @@
+# File in drupal-drupal-test-overwrite that is not replaced by a scaffold file.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/replace-me.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/replace-me.txt
new file mode 100644
index 0000000000..4147b02214
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/replace-me.txt
@@ -0,0 +1 @@
+# File in drupal-drupal-test-overwrite that is replaced by a scaffold file.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/assets/robots-default.txt
new file mode 100644
index 0000000000..6eb30e86aa
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/composer.json.tmpl
new file mode 100644
index 0000000000..a8641ef7a2
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/composer.json.tmpl
@@ -0,0 +1,74 @@
+{
+  "name": "fixtures/drupal-drupal",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*",
+    "fixtures/scaffold-override-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "gitignore": false,
+      "overwrite": true,
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false,
+        "[web-root]/robots.txt": {
+          "mode": "replace",
+          "path": "assets/robots-default.txt",
+          "overwrite": true
+        }
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/assets/profile.default.services.yml b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/assets/profile.default.services.yml
new file mode 100644
index 0000000000..2a35c02dba
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/assets/profile.default.services.yml
@@ -0,0 +1,4 @@
+# default.services.yml fixture scaffolded from "file-mappings" in drupal-project composer.json fixture.
+# Add a dummy key until YamlPecl can validate an empty YAML file:
+# https://www.drupal.org/project/drupal/issues/3003300
+foo: bar
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/composer.json.tmpl
new file mode 100644
index 0000000000..b3e4b76640
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/composer.json.tmpl
@@ -0,0 +1,10 @@
+{
+  "name": "fixtures/drupal-profile",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/.htaccess": false
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture-allowing-core/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture-allowing-core/composer.json
new file mode 100644
index 0000000000..66e96bf38b
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture-allowing-core/composer.json
@@ -0,0 +1,13 @@
+{
+  "name": "fixtures/empty-fixture-allowing-core",
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture/composer.json
new file mode 100644
index 0000000000..7eb36a74cb
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture/composer.json
@@ -0,0 +1,3 @@
+{
+  "name": "fixtures/empty-fixture"
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/packages.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/packages.json
new file mode 100644
index 0000000000..de4226972a
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/packages.json
@@ -0,0 +1,14 @@
+{
+    "packages": {
+        "fixtures/drupal-drupal": {
+            "dev-master": {
+                "name": "fixtures/drupal-drupal",
+                "version": "1.0.0",
+                "dist": {
+                    "url": "./drupal-drupal",
+                    "type": "path"
+                }
+            }
+        }
+    }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-allowing-empty-fixture/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-allowing-empty-fixture/composer.json.tmpl
new file mode 100644
index 0000000000..254492a6e9
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-allowing-empty-fixture/composer.json.tmpl
@@ -0,0 +1,77 @@
+{
+  "name": "fixtures/project-allowing-empty-fixture",
+  "type": "project",
+  "minimum-stability": "dev",
+  "prefer-stable": true,
+  "repositories": {
+    "composer-scaffold": {
+      "type": "path",
+      "url": "__PROJECT_ROOT__",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-core-fixture": {
+      "type": "path",
+      "url": "../drupal-core-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "drupal-assets-fixture": {
+      "type": "path",
+      "url": "../drupal-assets-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "empty-fixture": {
+      "type": "path",
+      "url": "../empty-fixture",
+      "options": {
+        "symlink": true
+      }
+    },
+    "scaffold-override-fixture": {
+      "type": "path",
+      "url": "../scaffold-override-fixture",
+      "options": {
+        "symlink": true
+      }
+    }
+  },
+  "require": {
+    "drupal/core-composer-scaffold": "*",
+    "fixtures/drupal-core-fixture": "*",
+    "fixtures/empty-fixture": "*",
+    "fixtures/scaffold-override-fixture": "*"
+  },
+  "extra": {
+    "composer-scaffold": {
+      "allowed-packages": [
+        "fixtures/drupal-core-fixture",
+        "fixtures/empty-fixture",
+        "fixtures/scaffold-override-fixture"
+      ],
+      "locations": {
+        "web-root": "./"
+      },
+      "gitignore": false,
+      "symlink": __SYMLINK__,
+      "file-mapping": {
+        "[web-root]/.htaccess": false
+      }
+    },
+    "installer-paths": {
+      "core": ["type:drupal-core"],
+      "modules/contrib/{$name}": ["type:drupal-module"],
+      "modules/custom/{$name}": ["type:drupal-custom-module"],
+      "profiles/contrib/{$name}": ["type:drupal-profile"],
+      "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+      "themes/contrib/{$name}": ["type:drupal-theme"],
+      "themes/custom/{$name}": ["type:drupal-custom-theme"],
+      "libraries/{$name}": ["type:drupal-library"],
+      "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-empty-scaffold-path/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-empty-scaffold-path/composer.json
new file mode 100644
index 0000000000..e74df96ff0
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-empty-scaffold-path/composer.json
@@ -0,0 +1,15 @@
+{
+  "name": "fixtures/project-with-empty-scaffold-path",
+  "extra": {
+    "composer-scaffold": {
+      "locations": {
+        "web-root": "./"
+      },
+      "file-mapping": {
+        "[web-root]/my-error": {
+            "path": ""
+        }
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/assets/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/assets/README.md
new file mode 100644
index 0000000000..7e59600739
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/assets/README.md
@@ -0,0 +1 @@
+# README
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/composer.json
new file mode 100644
index 0000000000..5cebfd4b57
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/composer.json
@@ -0,0 +1,15 @@
+{
+  "name": "fixtures/project-with-illegal-dir-scaffold",
+  "extra": {
+    "composer-scaffold": {
+      "locations": {
+        "web-root": "./"
+      },
+      "file-mapping": {
+        "[web-root]/assets": {
+            "path": "assets"
+        }
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/assets/override-settings.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/assets/override-settings.php
new file mode 100644
index 0000000000..f37a684279
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/assets/override-settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This is a settings.php fixture file scaffolded from the scaffold-override-fixture.
+ */
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/composer.json
new file mode 100644
index 0000000000..977af8a76e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/composer.json
@@ -0,0 +1,10 @@
+{
+  "name": "fixtures/scaffold-override-fixture",
+  "extra": {
+    "composer-scaffold": {
+      "file-mapping": {
+        "[web-root]/sites/default/default.settings.php": "assets/override-settings.php"
+      }
+    }
+  }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scripts/disable-git-bin/git b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scripts/disable-git-bin/git
new file mode 100755
index 0000000000..36fa746230
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scripts/disable-git-bin/git
@@ -0,0 +1,2 @@
+#!/bin/bash
+exit 127
