diff --git a/core/core.services.yml b/core/core.services.yml
index d1223e9..05f128e 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -349,6 +349,9 @@ services:
   file_system:
     class: Drupal\Core\File\FileSystem
     arguments: ['@stream_wrapper_manager', '@settings', '@logger.channel.file']
+  file_handler.unmanaged:
+    class: Drupal\Core\File\UnmanagedFileHandler
+    arguments: ['@file_system']
   form_builder:
     class: Drupal\Core\Form\FormBuilder
     arguments: ['@form_validator', '@form_submitter', '@form_cache', '@module_handler', '@event_dispatcher', '@request_stack', '@class_resolver', '@element_info', '@theme.manager', '@?csrf_token']
diff --git a/core/includes/file.inc b/core/includes/file.inc
index 0dcd141..c033a36 100644
--- a/core/includes/file.inc
+++ b/core/includes/file.inc
@@ -10,6 +10,7 @@
 use Drupal\Component\Utility\UrlHelper;
 use Drupal\Component\PhpStorage\FileStorage;
 use Drupal\Component\Utility\Bytes;
+use Drupal\Core\File\Exception\FileExistsException;
 use Drupal\Core\File\FileSystem;
 use Drupal\Core\Site\Settings;
 use Drupal\Core\StreamWrapper\PublicStream;
@@ -49,16 +50,25 @@
 
 /**
  * Flag for dealing with existing files: Appends number until name is unique.
+ *
+ * @deprecated in Drupal 8.3.x, will be removed before Drupal 9.0.0.
+ *   Use \Drupal\Core\File\FileHandlerInterface::FILE_EXISTS_RENAME.
  */
 const FILE_EXISTS_RENAME = 0;
 
 /**
  * Flag for dealing with existing files: Replace the existing file.
+ *
+ * @deprecated in Drupal 8.3.x, will be removed before Drupal 9.0.0.
+ *   Use \Drupal\Core\File\FileHandlerInterface::FILE_EXISTS_REPLACE.
  */
 const FILE_EXISTS_REPLACE = 1;
 
 /**
  * Flag for dealing with existing files: Do nothing and return FALSE.
+ *
+ * @deprecated in Drupal 8.3.x, will be removed before Drupal 9.0.0.
+ *   Use \Drupal\Core\File\FileHandlerInterface::FILE_EXISTS_ERROR.
  */
 const FILE_EXISTS_ERROR = 2;
 
@@ -445,23 +455,18 @@ function file_valid_uri($uri) {
  *   The path to the new file, or FALSE in the event of an error.
  *
  * @see file_copy()
+ *
+ * @deprecated in Drupal 8.3.x, will be removed before Drupal 9.0.0.
+ *   Use \Drupal\Core\File\UnmanagedFileHandler::copy().
  */
 function file_unmanaged_copy($source, $destination = NULL, $replace = FILE_EXISTS_RENAME) {
-  if (!file_unmanaged_prepare($source, $destination, $replace)) {
-    return FALSE;
+  try {
+    return \Drupal::service('file_handler.unmanaged')->copy($source, $destination, $replace);
   }
-  // Attempt to resolve the URIs. This is necessary in certain configurations
-  // (see above).
-  $real_source = drupal_realpath($source) ?: $source;
-  $real_destination = drupal_realpath($destination) ?: $destination;
-  // Perform the copy operation.
-  if (!@copy($real_source, $real_destination)) {
-    \Drupal::logger('file')->error('The specified file %file could not be copied to %destination.', array('%file' => $source, '%destination' => $destination));
+  catch (FileExistsException $e) {
+    \Drupal::logger('file')->error($e->getMessage());
     return FALSE;
   }
-  // Set the permissions on the new file.
-  drupal_chmod($destination);
-  return $destination;
 }
 
 /**
diff --git a/core/lib/Drupal/Core/File/Exception/DirectoryNotReadyException.php b/core/lib/Drupal/Core/File/Exception/DirectoryNotReadyException.php
new file mode 100644
index 0000000..7750d7c
--- /dev/null
+++ b/core/lib/Drupal/Core/File/Exception/DirectoryNotReadyException.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace Drupal\Core\File\Exception;
+
+/**
+ * Exception thrown when a file's destination directory is not ready.
+ *
+ * A directory can be considered not ready when it either does not exist, or
+ * is not writable.
+ */
+class DirectoryNotReadyException extends FileException {
+}
diff --git a/core/lib/Drupal/Core/File/Exception/FileException.php b/core/lib/Drupal/Core/File/Exception/FileException.php
new file mode 100644
index 0000000..ef8db92
--- /dev/null
+++ b/core/lib/Drupal/Core/File/Exception/FileException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\Core\File\Exception;
+
+/**
+ * Base class for exceptions related to file handling operations.
+ */
+class FileException extends \Exception {
+}
diff --git a/core/lib/Drupal/Core/File/Exception/FileExistsException.php b/core/lib/Drupal/Core/File/Exception/FileExistsException.php
new file mode 100644
index 0000000..3b9ff5a
--- /dev/null
+++ b/core/lib/Drupal/Core/File/Exception/FileExistsException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\Core\File\Exception;
+
+/**
+ * Exception thrown when a file unexpectedly exists.
+ */
+class FileExistsException extends FileException {
+}
diff --git a/core/lib/Drupal/Core/File/Exception/NonExistentFileException.php b/core/lib/Drupal/Core/File/Exception/NonExistentFileException.php
new file mode 100644
index 0000000..294a8cf
--- /dev/null
+++ b/core/lib/Drupal/Core/File/Exception/NonExistentFileException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\Core\File\Exception;
+
+/**
+ * Exception thrown when a file is expected to exist, but does not.
+ */
+class NonExistentFileException extends FileException {
+}
diff --git a/core/lib/Drupal/Core/File/FileHandlerInterface.php b/core/lib/Drupal/Core/File/FileHandlerInterface.php
new file mode 100644
index 0000000..e1554b2
--- /dev/null
+++ b/core/lib/Drupal/Core/File/FileHandlerInterface.php
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\File\FileHandlerInterface.
+ */
+
+namespace Drupal\Core\File;
+
+/**
+ * Defines an interface for handling file operations.
+ */
+interface FileHandlerInterface {
+
+  /**
+   * Flag for dealing with existing files: Appends number until name is unique.
+   */
+  const FILE_EXISTS_RENAME = 0;
+
+  /**
+   * Flag for dealing with existing files: Replace the existing file.
+   */
+  const FILE_EXISTS_REPLACE = 1;
+
+  /**
+   * Flag for dealing with existing files: Do nothing and return FALSE.
+   */
+  const FILE_EXISTS_ERROR = 2;
+
+}
diff --git a/core/lib/Drupal/Core/File/UnmanagedFileHandler.php b/core/lib/Drupal/Core/File/UnmanagedFileHandler.php
new file mode 100644
index 0000000..228e153
--- /dev/null
+++ b/core/lib/Drupal/Core/File/UnmanagedFileHandler.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\Core\File;
+
+use Drupal\Core\File\Exception\DirectoryNotReadyException;
+use Drupal\Core\File\Exception\FileException;
+use Drupal\Core\File\Exception\FileExistsException;
+use Drupal\Core\File\Exception\NonExistentFileException;
+
+class UnmanagedFileHandler implements UnmanagedFileHandlerInterface {
+
+  /**
+   * The file_system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * UnmanagedFileHandler constructor.
+   *
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file_system service.
+   */
+  public function __construct(FileSystemInterface $file_system) {
+    $this->fileSystem = $file_system;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function copy($source, $destination, $replace = self::FILE_EXISTS_RENAME) {
+    $original_source = $source;
+
+    // Assert that the source file actually exists.
+    if (!file_exists($source)) {
+      if (($realpath = $this->fileSystem->realpath($original_source)) !== FALSE) {
+        throw new NonExistentFileException("File '$original_source' ('$realpath') could not be copied because it does not exist");
+      }
+      else {
+        throw new NonExistentFileException("File '$original_source' could not be copied because it does not exist");
+      }
+    }
+
+    // Build a destination URI if necessary.
+    if (!isset($destination)) {
+      $destination = file_build_uri($this->fileSystem->basename($source));
+    }
+
+    // Prepare the destination directory.
+    if (file_prepare_directory($destination)) {
+      // The destination is already a directory, so append the source basename.
+      $destination = file_stream_wrapper_uri_normalize($destination . '/' . $this->fileSystem->basename($source));
+    }
+    else {
+      // Perhaps $destination is a dir/file?
+      $dirname = $this->fileSystem->dirname($destination);
+      if (!file_prepare_directory($dirname)) {
+        throw new DirectoryNotReadyException("The specified file '$original_source' could not be copied because the destination directory is not properly configured. This may be caused by a problem with file or directory permissions");
+      }
+    }
+
+    // Determine whether we can perform this operation based on overwrite rules.
+    $destination = file_destination($destination, $replace);
+    if ($destination === FALSE) {
+      throw new FileExistsException("File '$original_source' could not be copied because a file by that name already exists in the destination directory ('$destination')");
+    }
+
+    // Assert that the source and destination filenames are not the same.
+    $real_source = $this->fileSystem->realpath($source);
+    $real_destination = $this->fileSystem->realpath($destination);
+    if ($source == $destination || ($real_source !== FALSE) && ($real_source == $real_destination)) {
+      throw new FileException("File '$source' could not be copied because it would overwrite itself");
+    }
+    // Make sure the .htaccess files are present.
+    file_ensure_htaccess();
+    // Perform the copy operation.
+    if (!@copy($source, $destination)) {
+      // If the copy failed and realpaths exist, retry the operation using them
+      // instead.
+      if ($real_source === FALSE || $real_destination === FALSE || !@copy($real_source, $real_destination)) {
+        throw new FileException("The specified file '$source' could not be copied to '$destination'");
+      }
+    }
+
+    // Set the permissions on the new file.
+    $this->fileSystem->chmod($destination);
+
+    return $destination;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/File/UnmanagedFileHandlerInterface.php b/core/lib/Drupal/Core/File/UnmanagedFileHandlerInterface.php
new file mode 100644
index 0000000..c293217
--- /dev/null
+++ b/core/lib/Drupal/Core/File/UnmanagedFileHandlerInterface.php
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\File\UnmanagedFileHandlerInterface.
+ */
+
+namespace Drupal\Core\File;
+
+/**
+ * Defines an interface for handling unmanaged files.
+ *
+ * Unmanaged files have a URI, but they are not file entities and are not stored
+ * or tracked in the Drupal database.
+ */
+interface UnmanagedFileHandlerInterface extends FileHandlerInterface {
+
+  /**
+   * Copies a file to a new location without invoking the file API.
+   *
+   * This is a powerful function that in many ways performs like an advanced
+   * version of copy().
+   * - Checks if $source and $destination are valid and readable/writable.
+   * - If file already exists in $destination either the call will error out,
+   *   replace the file or rename the file based on the $replace parameter.
+   * - If the $source and $destination are equal, the behavior depends on the
+   *   $replace parameter. FILE_EXISTS_REPLACE will error out.
+   *   FILE_EXISTS_RENAME will rename the file until the $destination is unique.
+   * - Provides a fallback using realpaths if the move fails using stream
+   *   wrappers. This can occur because PHP's copy() function does not properly
+   *   support streams if open_basedir is enabled. See
+   *   https://bugs.php.net/bug.php?id=60456
+   *
+   * @param string $source
+   *   A string specifying the filepath or URI of the source file.
+   * @param string $destination
+   *   A URI containing the destination that $source should be copied to. The
+   *   URI may be a bare filepath (without a scheme). If this value is omitted,
+   *   Drupal's default files scheme will be used, usually "public://".
+   * @param $replace
+   *   Replace behavior when the destination file already exists:
+   *   - FileManagerInterface::FILE_EXISTS_REPLACE - Replace the existing file.
+   *   - FileManagerInterface::FILE_EXISTS_RENAME - Append _{incrementing
+   *     number} until the filename is unique.
+   *   - FileManagerInterface::FILE_EXISTS_ERROR - Throw an exception.
+   *
+   * @return
+   *   The path to the new file.
+   *
+   * @throws \Drupal\Core\File\Exception\DirectoryNotReadyException
+   * @throws \Drupal\Core\File\Exception\FileException
+   * @throws \Drupal\Core\File\Exception\FileExistsException
+   * @throws \Drupal\Core\File\Exception\NonExistentFileException
+   *
+   * @see file_copy()
+   */
+  public function copy($source, $destination, $replace = self::FILE_EXISTS_RENAME);
+
+}
diff --git a/core/tests/Drupal/Tests/Core/File/UnmanagedFileHandlerTest.php b/core/tests/Drupal/Tests/Core/File/UnmanagedFileHandlerTest.php
new file mode 100644
index 0000000..be1a09e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/File/UnmanagedFileHandlerTest.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Drupal\Tests\Core\File;
+
+use Drupal\Core\File\Exception\NonExistentFileException;
+use Drupal\Core\File\FileHandlerInterface;
+use Drupal\Core\File\UnmanagedFileHandler;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\File\UnmanagedFileHandler
+ * @group File
+ */
+class UnmanagedFileHandlerTest extends KernelTestBase {
+
+  /**
+   * The file handler under test.
+   *
+   * @var \Drupal\Core\File\UnmanagedFileHandler
+   */
+  protected $fileHandler;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['system'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->fileHandler = new UnmanagedFileHandler(
+      $this->container->get('file_system')
+    );
+  }
+
+  /**
+   * @covers ::copy
+   */
+  public function testEnsureFileExistsBeforeCopy() {
+    // We need to compute the exception message here because it will include
+    // the 'real' path to the file, which varies with $this->siteDirectory.
+    $this->setExpectedException(
+      NonExistentFileException::class,
+      "File 'public://test.txt' ('{$this->siteDirectory}/files/test.txt') could not be copied because it does not exist"
+    );
+
+    $this->fileHandler->copy('public://test.txt', 'public://test-copy.txt');
+  }
+
+  /**
+   * @covers ::copy
+   * @expectedException \Drupal\Core\File\Exception\DirectoryNotReadyException
+   * @expectedExceptionMessage The specified file 'public://test.txt' could not be copied because the destination directory is not properly configured. This may be caused by a problem with file or directory permissions
+   */
+  public function testDestinationDirectoryFailureOnCopy() {
+    touch('public://test.txt');
+    // public://subdirectory has not been created, so file_prepare_directory()
+    // will fail, causing copy() to throw DirectoryNotReadyException.
+    $this->fileHandler->copy('public://test.txt', 'public://subdirectory/test.txt');
+  }
+
+  /**
+   * @covers ::copy
+   * @expectedException \Drupal\Core\File\Exception\FileExistsException
+   * @expectedExceptionMessage File 'public://test.txt' could not be copied because a file by that name already exists in the destination directory ('')
+   */
+  public function testCopyFailureIfFileAlreadyExists() {
+    $uri = 'public://test.txt';
+    touch($uri);
+    $this->fileHandler->copy($uri, $uri, FileHandlerInterface::FILE_EXISTS_ERROR);
+  }
+
+  /**
+   * @covers ::copy
+   * @expectedException \Drupal\Core\File\Exception\FileException
+   * @expectedException File 'public://test.txt' cannot be copied because it would overwrite itself
+   */
+  public function testCopyFailureIfSelfOverwrite() {
+    $uri = 'public://test.txt';
+    $this->fileHandler->copy($uri, $uri);
+  }
+
+  /**
+   * @covers ::copy
+   */
+  public function testSuccessfulCopy() {
+    touch('public://test.txt');
+    $this->fileHandler->copy('public://test.txt', 'public://test-copy.txt');
+    $this->assertFileExists('public://test-copy.txt');
+  }
+
+}
