diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -3380,16 +3380,20 @@
     'PhabricatorPhurlShortURLDefaultController' => 'applications/phurl/controller/PhabricatorPhurlShortURLDefaultController.php',
     'PhabricatorPhurlURL' => 'applications/phurl/storage/PhabricatorPhurlURL.php',
     'PhabricatorPhurlURLAccessController' => 'applications/phurl/controller/PhabricatorPhurlURLAccessController.php',
+    'PhabricatorPhurlURLAliasTransaction' => 'applications/phurl/xaction/PhabricatorPhurlURLAliasTransaction.php',
     'PhabricatorPhurlURLCommentController' => 'applications/phurl/controller/PhabricatorPhurlURLCommentController.php',
     'PhabricatorPhurlURLCreateCapability' => 'applications/phurl/capability/PhabricatorPhurlURLCreateCapability.php',
     'PhabricatorPhurlURLDatasource' => 'applications/phurl/typeahead/PhabricatorPhurlURLDatasource.php',
+    'PhabricatorPhurlURLDescriptionTransaction' => 'applications/phurl/xaction/PhabricatorPhurlURLDescriptionTransaction.php',
     'PhabricatorPhurlURLEditConduitAPIMethod' => 'applications/phurl/conduit/PhabricatorPhurlURLEditConduitAPIMethod.php',
     'PhabricatorPhurlURLEditController' => 'applications/phurl/controller/PhabricatorPhurlURLEditController.php',
     'PhabricatorPhurlURLEditEngine' => 'applications/phurl/editor/PhabricatorPhurlURLEditEngine.php',
     'PhabricatorPhurlURLEditor' => 'applications/phurl/editor/PhabricatorPhurlURLEditor.php',
     'PhabricatorPhurlURLListController' => 'applications/phurl/controller/PhabricatorPhurlURLListController.php',
+    'PhabricatorPhurlURLLongURLTransaction' => 'applications/phurl/xaction/PhabricatorPhurlURLLongURLTransaction.php',
     'PhabricatorPhurlURLMailReceiver' => 'applications/phurl/mail/PhabricatorPhurlURLMailReceiver.php',
     'PhabricatorPhurlURLNameNgrams' => 'applications/phurl/storage/PhabricatorPhurlURLNameNgrams.php',
+    'PhabricatorPhurlURLNameTransaction' => 'applications/phurl/xaction/PhabricatorPhurlURLNameTransaction.php',
     'PhabricatorPhurlURLPHIDType' => 'applications/phurl/phid/PhabricatorPhurlURLPHIDType.php',
     'PhabricatorPhurlURLQuery' => 'applications/phurl/query/PhabricatorPhurlURLQuery.php',
     'PhabricatorPhurlURLReplyHandler' => 'applications/phurl/mail/PhabricatorPhurlURLReplyHandler.php',
@@ -3398,6 +3402,7 @@
     'PhabricatorPhurlURLTransaction' => 'applications/phurl/storage/PhabricatorPhurlURLTransaction.php',
     'PhabricatorPhurlURLTransactionComment' => 'applications/phurl/storage/PhabricatorPhurlURLTransactionComment.php',
     'PhabricatorPhurlURLTransactionQuery' => 'applications/phurl/query/PhabricatorPhurlURLTransactionQuery.php',
+    'PhabricatorPhurlURLTransactionType' => 'applications/phurl/xaction/PhabricatorPhurlURLTransactionType.php',
     'PhabricatorPhurlURLViewController' => 'applications/phurl/controller/PhabricatorPhurlURLViewController.php',
     'PhabricatorPinnedApplicationsSetting' => 'applications/settings/setting/PhabricatorPinnedApplicationsSetting.php',
     'PhabricatorPirateEnglishTranslation' => 'infrastructure/internationalization/translation/PhabricatorPirateEnglishTranslation.php',
@@ -8545,24 +8550,29 @@
       'PhabricatorNgramsInterface',
     ),
     'PhabricatorPhurlURLAccessController' => 'PhabricatorPhurlController',
+    'PhabricatorPhurlURLAliasTransaction' => 'PhabricatorPhurlURLTransactionType',
     'PhabricatorPhurlURLCommentController' => 'PhabricatorPhurlController',
     'PhabricatorPhurlURLCreateCapability' => 'PhabricatorPolicyCapability',
     'PhabricatorPhurlURLDatasource' => 'PhabricatorTypeaheadDatasource',
+    'PhabricatorPhurlURLDescriptionTransaction' => 'PhabricatorPhurlURLTransactionType',
     'PhabricatorPhurlURLEditConduitAPIMethod' => 'PhabricatorEditEngineAPIMethod',
     'PhabricatorPhurlURLEditController' => 'PhabricatorPhurlController',
     'PhabricatorPhurlURLEditEngine' => 'PhabricatorEditEngine',
     'PhabricatorPhurlURLEditor' => 'PhabricatorApplicationTransactionEditor',
     'PhabricatorPhurlURLListController' => 'PhabricatorPhurlController',
+    'PhabricatorPhurlURLLongURLTransaction' => 'PhabricatorPhurlURLTransactionType',
     'PhabricatorPhurlURLMailReceiver' => 'PhabricatorObjectMailReceiver',
     'PhabricatorPhurlURLNameNgrams' => 'PhabricatorSearchNgrams',
+    'PhabricatorPhurlURLNameTransaction' => 'PhabricatorPhurlURLTransactionType',
     'PhabricatorPhurlURLPHIDType' => 'PhabricatorPHIDType',
     'PhabricatorPhurlURLQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
     'PhabricatorPhurlURLReplyHandler' => 'PhabricatorApplicationTransactionReplyHandler',
     'PhabricatorPhurlURLSearchConduitAPIMethod' => 'PhabricatorSearchEngineAPIMethod',
     'PhabricatorPhurlURLSearchEngine' => 'PhabricatorApplicationSearchEngine',
-    'PhabricatorPhurlURLTransaction' => 'PhabricatorApplicationTransaction',
+    'PhabricatorPhurlURLTransaction' => 'PhabricatorModularTransaction',
     'PhabricatorPhurlURLTransactionComment' => 'PhabricatorApplicationTransactionComment',
     'PhabricatorPhurlURLTransactionQuery' => 'PhabricatorApplicationTransactionQuery',
+    'PhabricatorPhurlURLTransactionType' => 'PhabricatorModularTransactionType',
     'PhabricatorPhurlURLViewController' => 'PhabricatorPhurlController',
     'PhabricatorPinnedApplicationsSetting' => 'PhabricatorInternalSetting',
     'PhabricatorPirateEnglishTranslation' => 'PhutilTranslation',
diff --git a/src/applications/phurl/editor/PhabricatorPhurlURLEditEngine.php b/src/applications/phurl/editor/PhabricatorPhurlURLEditEngine.php
--- a/src/applications/phurl/editor/PhabricatorPhurlURLEditEngine.php
+++ b/src/applications/phurl/editor/PhabricatorPhurlURLEditEngine.php
@@ -21,6 +21,10 @@
     return pht('Configure creation and editing forms in Phurl.');
   }
 
+  public function isEngineConfigurable() {
+    return false;
+  }
+
   protected function newEditableObject() {
     return PhabricatorPhurlURL::initializeNewPhurlURL($this->getViewer());
   }
@@ -73,8 +77,10 @@
         ->setKey('name')
         ->setLabel(pht('Name'))
         ->setDescription(pht('URL name.'))
+        ->setIsRequired(true)
         ->setConduitTypeDescription(pht('New URL name.'))
-        ->setTransactionType(PhabricatorPhurlURLTransaction::TYPE_NAME)
+        ->setTransactionType(
+          PhabricatorPhurlURLNameTransaction::TRANSACTIONTYPE)
         ->setValue($object->getName()),
       id(new PhabricatorTextEditField())
         ->setKey('url')
@@ -83,11 +89,14 @@
         ->setConduitTypeDescription(pht('New URL.'))
         ->setValue($object->getLongURL())
         ->setIsRequired(true)
-        ->setTransactionType(PhabricatorPhurlURLTransaction::TYPE_URL),
+        ->setTransactionType(
+          PhabricatorPhurlURLLongURLTransaction::TRANSACTIONTYPE),
       id(new PhabricatorTextEditField())
         ->setKey('alias')
         ->setLabel(pht('Alias'))
-        ->setTransactionType(PhabricatorPhurlURLTransaction::TYPE_ALIAS)
+        ->setIsRequired(true)
+        ->setTransactionType(
+          PhabricatorPhurlURLAliasTransaction::TRANSACTIONTYPE)
         ->setDescription(pht('The alias to give the URL.'))
         ->setConduitTypeDescription(pht('New alias.'))
         ->setValue($object->getAlias()),
@@ -96,7 +105,8 @@
         ->setLabel(pht('Description'))
         ->setDescription(pht('URL long description.'))
         ->setConduitTypeDescription(pht('New URL description.'))
-        ->setTransactionType(PhabricatorPhurlURLTransaction::TYPE_DESCRIPTION)
+        ->setTransactionType(
+          PhabricatorPhurlURLDescriptionTransaction::TRANSACTIONTYPE)
         ->setValue($object->getDescription()),
     );
   }
diff --git a/src/applications/phurl/editor/PhabricatorPhurlURLEditor.php b/src/applications/phurl/editor/PhabricatorPhurlURLEditor.php
--- a/src/applications/phurl/editor/PhabricatorPhurlURLEditor.php
+++ b/src/applications/phurl/editor/PhabricatorPhurlURLEditor.php
@@ -11,188 +11,42 @@
     return pht('Phurl');
   }
 
-  public function getTransactionTypes() {
-    $types = parent::getTransactionTypes();
-
-    $types[] = PhabricatorPhurlURLTransaction::TYPE_NAME;
-    $types[] = PhabricatorPhurlURLTransaction::TYPE_URL;
-    $types[] = PhabricatorPhurlURLTransaction::TYPE_ALIAS;
-    $types[] = PhabricatorPhurlURLTransaction::TYPE_DESCRIPTION;
-
-    $types[] = PhabricatorTransactions::TYPE_COMMENT;
-    $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
-    $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY;
-
-    return $types;
-  }
-
-  protected function getCustomTransactionOldValue(
-    PhabricatorLiskDAO $object,
-    PhabricatorApplicationTransaction $xaction) {
-    switch ($xaction->getTransactionType()) {
-      case PhabricatorPhurlURLTransaction::TYPE_NAME:
-        return $object->getName();
-      case PhabricatorPhurlURLTransaction::TYPE_URL:
-        return $object->getLongURL();
-      case PhabricatorPhurlURLTransaction::TYPE_ALIAS:
-        return $object->getAlias();
-      case PhabricatorPhurlURLTransaction::TYPE_DESCRIPTION:
-        return $object->getDescription();
-    }
-
-    return parent::getCustomTransactionOldValue($object, $xaction);
-  }
-
-  protected function getCustomTransactionNewValue(
-    PhabricatorLiskDAO $object,
-    PhabricatorApplicationTransaction $xaction) {
-    switch ($xaction->getTransactionType()) {
-      case PhabricatorPhurlURLTransaction::TYPE_NAME:
-      case PhabricatorPhurlURLTransaction::TYPE_URL:
-      case PhabricatorPhurlURLTransaction::TYPE_DESCRIPTION:
-        return $xaction->getNewValue();
-      case PhabricatorPhurlURLTransaction::TYPE_ALIAS:
-        if (!strlen($xaction->getNewValue())) {
-          return null;
-        }
-        return $xaction->getNewValue();
-    }
-
-    return parent::getCustomTransactionNewValue($object, $xaction);
+  public function getCreateObjectTitle($author, $object) {
+    return pht('%s created this URL.', $author);
   }
 
-  protected function applyCustomInternalTransaction(
-    PhabricatorLiskDAO $object,
-    PhabricatorApplicationTransaction $xaction) {
-
-    switch ($xaction->getTransactionType()) {
-      case PhabricatorPhurlURLTransaction::TYPE_NAME:
-        $object->setName($xaction->getNewValue());
-        return;
-      case PhabricatorPhurlURLTransaction::TYPE_URL:
-        $object->setLongURL($xaction->getNewValue());
-        return;
-      case PhabricatorPhurlURLTransaction::TYPE_ALIAS:
-        $object->setAlias($xaction->getNewValue());
-        return;
-      case PhabricatorPhurlURLTransaction::TYPE_DESCRIPTION:
-        $object->setDescription($xaction->getNewValue());
-        return;
-    }
-
-    return parent::applyCustomInternalTransaction($object, $xaction);
+  public function getCreateObjectTitleForFeed($author, $object) {
+    return pht('%s created %s.', $author, $object);
   }
 
-  protected function applyCustomExternalTransaction(
-    PhabricatorLiskDAO $object,
-    PhabricatorApplicationTransaction $xaction) {
-
-    switch ($xaction->getTransactionType()) {
-      case PhabricatorPhurlURLTransaction::TYPE_NAME:
-      case PhabricatorPhurlURLTransaction::TYPE_URL:
-      case PhabricatorPhurlURLTransaction::TYPE_ALIAS:
-      case PhabricatorPhurlURLTransaction::TYPE_DESCRIPTION:
-        return;
-    }
-
-    return parent::applyCustomExternalTransaction($object, $xaction);
+  protected function supportsSearch() {
+    return true;
   }
 
-  protected function validateTransaction(
-    PhabricatorLiskDAO $object,
-    $type,
-    array $xactions) {
-
-    $errors = parent::validateTransaction($object, $type, $xactions);
-
-    switch ($type) {
-      case PhabricatorPhurlURLTransaction::TYPE_ALIAS:
-        $overdrawn = $this->validateIsTextFieldTooLong(
-          $object->getName(),
-          $xactions,
-          64);
-
-        if ($overdrawn) {
-          $errors[] = new PhabricatorApplicationTransactionValidationError(
-            $type,
-            pht('Alias Too Long'),
-            pht('The alias can be no longer than 64 characters.'),
-            nonempty(last($xactions), null));
-        }
-
-        foreach ($xactions as $xaction) {
-          if ($xaction->getOldValue() != $xaction->getNewValue()) {
-            $new_alias = $xaction->getNewValue();
-            $debug_alias = new PHUIInvisibleCharacterView($new_alias);
-            if (!preg_match('/[a-zA-Z]/', $new_alias)) {
-              $errors[] = new PhabricatorApplicationTransactionValidationError(
-                $type,
-                pht('Invalid Alias'),
-                pht('The alias you provided (%s) must contain at least one '.
-                  'letter.',
-                  $debug_alias),
-                $xaction);
-            }
-            if (preg_match('/[^a-z0-9]/i', $new_alias)) {
-              $errors[] = new PhabricatorApplicationTransactionValidationError(
-                $type,
-                pht('Invalid Alias'),
-                pht('The alias you provided (%s) may only contain letters and '.
-                  'numbers.',
-                  $debug_alias),
-                $xaction);
-            }
-          }
-        }
-
-        break;
-      case PhabricatorPhurlURLTransaction::TYPE_URL:
-        $missing = $this->validateIsEmptyTextField(
-          $object->getLongURL(),
-          $xactions);
-
-        if ($missing) {
-          $error = new PhabricatorApplicationTransactionValidationError(
-            $type,
-            pht('Required'),
-            pht('URL path is required.'),
-            nonempty(last($xactions), null));
-
-          $error->setIsMissingFieldError(true);
-          $errors[] = $error;
-        }
-
-        foreach ($xactions as $xaction) {
-          if ($xaction->getOldValue() != $xaction->getNewValue()) {
-            $protocols = PhabricatorEnv::getEnvConfig('uri.allowed-protocols');
-            $uri = new PhutilURI($xaction->getNewValue());
-            if (!isset($protocols[$uri->getProtocol()])) {
-              $errors[] = new PhabricatorApplicationTransactionValidationError(
-                $type,
-                pht('Invalid URL'),
-                pht('The protocol of the URL is invalid.'),
-                null);
-            }
-          }
-        }
-
-        break;
-    }
+  public function getTransactionTypes() {
+    $types = parent::getTransactionTypes();
+    $types[] = PhabricatorTransactions::TYPE_COMMENT;
+    $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
+    $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY;
 
-    return $errors;
+    return $types;
   }
 
-  protected function shouldPublishFeedStory(
+  protected function shouldSendMail(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return true;
   }
 
-  protected function supportsSearch() {
-    return true;
+  public function getMailTagsMap() {
+    return array(
+      PhabricatorPhurlURLTransaction::MAILTAG_DETAILS =>
+        pht(
+          "A URL's details change."),
+    );
   }
 
-  protected function shouldSendMail(
+  protected function shouldPublishFeedStory(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return true;
@@ -204,20 +58,11 @@
 
   protected function getMailTo(PhabricatorLiskDAO $object) {
     $phids = array();
-
     $phids[] = $this->getActingAsPHID();
 
     return $phids;
   }
 
-  public function getMailTagsMap() {
-    return array(
-      PhabricatorPhurlURLTransaction::MAILTAG_DETAILS =>
-        pht(
-          "A URL's details change."),
-    );
-  }
-
   protected function buildMailTemplate(PhabricatorLiskDAO $object) {
     $id = $object->getID();
     $name = $object->getName();
@@ -255,7 +100,7 @@
 
     $errors = array();
     $errors[] = new PhabricatorApplicationTransactionValidationError(
-      PhabricatorPhurlURLTransaction::TYPE_ALIAS,
+      PhabricatorPhurlURLAliasTransaction::TRANSACTIONTYPE,
       pht('Duplicate'),
       pht('This alias is already in use.'),
       null);
diff --git a/src/applications/phurl/storage/PhabricatorPhurlURLTransaction.php b/src/applications/phurl/storage/PhabricatorPhurlURLTransaction.php
--- a/src/applications/phurl/storage/PhabricatorPhurlURLTransaction.php
+++ b/src/applications/phurl/storage/PhabricatorPhurlURLTransaction.php
@@ -1,12 +1,7 @@
 <?php
 
 final class PhabricatorPhurlURLTransaction
-  extends PhabricatorApplicationTransaction {
-
-  const TYPE_NAME = 'phurl.name';
-  const TYPE_URL = 'phurl.longurl';
-  const TYPE_ALIAS = 'phurl.alias';
-  const TYPE_DESCRIPTION = 'phurl.description';
+  extends PhabricatorModularTransaction {
 
   const MAILTAG_DETAILS = 'phurl-details';
 
@@ -22,14 +17,18 @@
     return new PhabricatorPhurlURLTransactionComment();
   }
 
+  public function getBaseTransactionClass() {
+    return 'PhabricatorPhurlURLTransactionType';
+  }
+
   public function getRequiredHandlePHIDs() {
     $phids = parent::getRequiredHandlePHIDs();
 
     switch ($this->getTransactionType()) {
-      case self::TYPE_NAME:
-      case self::TYPE_URL:
-      case self::TYPE_ALIAS:
-      case self::TYPE_DESCRIPTION:
+      case PhabricatorPhurlURLNameTransaction::TRANSACTIONTYPE:
+      case PhabricatorPhurlURLLongURLTransaction::TRANSACTIONTYPE:
+      case PhabricatorPhurlURLAliasTransaction::TRANSACTIONTYPE:
+      case PhabricatorPhurlURLDescriptionTransaction::TRANSACTIONTYPE:
         $phids[] = $this->getObjectPHID();
         break;
     }
@@ -37,203 +36,13 @@
     return $phids;
   }
 
-  public function shouldHide() {
-    $old = $this->getOldValue();
-    switch ($this->getTransactionType()) {
-      case self::TYPE_DESCRIPTION:
-        return ($old === null);
-    }
-    return parent::shouldHide();
-  }
-
-  public function getIcon() {
-    switch ($this->getTransactionType()) {
-      case self::TYPE_NAME:
-      case self::TYPE_URL:
-      case self::TYPE_ALIAS:
-      case self::TYPE_DESCRIPTION:
-        return 'fa-pencil';
-        break;
-    }
-    return parent::getIcon();
-  }
-
-  public function getTitle() {
-    $author_phid = $this->getAuthorPHID();
-    $object_phid = $this->getObjectPHID();
-
-    $old = $this->getOldValue();
-    $new = $this->getNewValue();
-
-    $type = $this->getTransactionType();
-    switch ($type) {
-      case self::TYPE_NAME:
-        if ($old === null) {
-          return pht(
-            '%s created this URL.',
-            $this->renderHandleLink($author_phid));
-        } else {
-          return pht(
-            '%s changed the name of the URL from %s to %s.',
-            $this->renderHandleLink($author_phid),
-            $old,
-            $new);
-        }
-      case self::TYPE_URL:
-        if ($old === null) {
-          return pht(
-            '%s set the destination of the URL to %s.',
-            $this->renderHandleLink($author_phid),
-            $new);
-        } else {
-          return pht(
-            '%s changed the destination of the URL from %s to %s.',
-            $this->renderHandleLink($author_phid),
-            $old,
-            $new);
-        }
-      case self::TYPE_ALIAS:
-        if ($old === null) {
-          return pht(
-            '%s set the alias of the URL to %s.',
-            $this->renderHandleLink($author_phid),
-            $new);
-        } else if ($new === null) {
-          return pht(
-            '%s removed the alias of the URL.',
-            $this->renderHandleLink($author_phid));
-        } else {
-          return pht(
-            '%s changed the alias of the URL from %s to %s.',
-            $this->renderHandleLink($author_phid),
-            $old,
-            $new);
-        }
-      case self::TYPE_DESCRIPTION:
-        return pht(
-          "%s updated the URL's description.",
-          $this->renderHandleLink($author_phid));
-    }
-    return parent::getTitle();
-  }
-
-  public function getTitleForFeed() {
-    $author_phid = $this->getAuthorPHID();
-    $object_phid = $this->getObjectPHID();
-
-    $old = $this->getOldValue();
-    $new = $this->getNewValue();
-
-    $viewer = $this->getViewer();
-
-    $type = $this->getTransactionType();
-    switch ($type) {
-      case self::TYPE_NAME:
-        if ($old === null) {
-          return pht(
-            '%s created %s.',
-            $this->renderHandleLink($author_phid),
-            $this->renderHandleLink($object_phid));
-        } else {
-          return pht(
-            '%s changed the name of %s from %s to %s.',
-            $this->renderHandleLink($author_phid),
-            $this->renderHandleLink($object_phid),
-            $old,
-            $new);
-        }
-      case self::TYPE_URL:
-        if ($old === null) {
-          return pht(
-            '%s set the destination of %s to %s.',
-            $this->renderHandleLink($author_phid),
-            $this->renderHandleLink($object_phid),
-            $new);
-        } else {
-          return pht(
-            '%s changed the destination of %s from %s to %s.',
-            $this->renderHandleLink($author_phid),
-            $this->renderHandleLink($object_phid),
-            $old,
-            $new);
-        }
-      case self::TYPE_ALIAS:
-        if ($old === null) {
-          return pht(
-            '%s set the alias of %s to %s.',
-            $this->renderHandleLink($author_phid),
-            $this->renderHandleLink($object_phid),
-            $new);
-        } else if ($new === null) {
-          return pht(
-            '%s removed the alias of %s.',
-            $this->renderHandleLink($author_phid),
-            $this->renderHandleLink($object_phid));
-        } else {
-          return pht(
-            '%s changed the alias of %s from %s to %s.',
-            $this->renderHandleLink($author_phid),
-            $this->renderHandleLink($object_phid),
-            $old,
-            $new);
-        }
-      case self::TYPE_DESCRIPTION:
-        return pht(
-          '%s updated the description of %s.',
-          $this->renderHandleLink($author_phid),
-          $this->renderHandleLink($object_phid));
-    }
-
-    return parent::getTitleForFeed();
-  }
-
-  public function getColor() {
-    $old = $this->getOldValue();
-    $new = $this->getNewValue();
-
-    switch ($this->getTransactionType()) {
-      case self::TYPE_NAME:
-      case self::TYPE_URL:
-      case self::TYPE_ALIAS:
-      case self::TYPE_DESCRIPTION:
-        return PhabricatorTransactions::COLOR_GREEN;
-    }
-
-    return parent::getColor();
-  }
-
-
-  public function hasChangeDetails() {
-    switch ($this->getTransactionType()) {
-      case self::TYPE_DESCRIPTION:
-        return ($this->getOldValue() !== null);
-    }
-
-    return parent::hasChangeDetails();
-  }
-
-  public function renderChangeDetails(PhabricatorUser $viewer) {
-    switch ($this->getTransactionType()) {
-      case self::TYPE_DESCRIPTION:
-        $old = $this->getOldValue();
-        $new = $this->getNewValue();
-
-        return $this->renderTextCorpusChangeDetails(
-          $viewer,
-          $old,
-          $new);
-    }
-
-    return parent::renderChangeDetails($viewer);
-  }
-
   public function getMailTags() {
     $tags = array();
     switch ($this->getTransactionType()) {
-      case self::TYPE_NAME:
-      case self::TYPE_DESCRIPTION:
-      case self::TYPE_URL:
-      case self::TYPE_ALIAS:
+      case PhabricatorPhurlURLNameTransaction::TRANSACTIONTYPE:
+      case PhabricatorPhurlURLLongURLTransaction::TRANSACTIONTYPE:
+      case PhabricatorPhurlURLAliasTransaction::TRANSACTIONTYPE:
+      case PhabricatorPhurlURLDescriptionTransaction::TRANSACTIONTYPE:
         $tags[] = self::MAILTAG_DETAILS;
         break;
     }
diff --git a/src/applications/phurl/xaction/PhabricatorPhurlURLAliasTransaction.php b/src/applications/phurl/xaction/PhabricatorPhurlURLAliasTransaction.php
new file mode 100644
--- /dev/null
+++ b/src/applications/phurl/xaction/PhabricatorPhurlURLAliasTransaction.php
@@ -0,0 +1,77 @@
+<?php
+
+final class PhabricatorPhurlURLAliasTransaction
+  extends PhabricatorPhurlURLTransactionType {
+
+  const TRANSACTIONTYPE = 'phurl.alias';
+
+  public function generateOldValue($object) {
+    return $object->getAlias();
+  }
+
+  public function applyInternalEffects($object, $value) {
+    $object->setAlias($value);
+  }
+
+  public function getTitle() {
+    return pht(
+      '%s changed the alias from %s to %s.',
+      $this->renderAuthor(),
+      $this->renderOldValue(),
+      $this->renderNewValue());
+  }
+
+  public function getTitleForFeed() {
+    return pht(
+      '%s changed the alias of %s from %s to %s.',
+      $this->renderAuthor(),
+      $this->renderObject(),
+      $this->renderOldValue(),
+      $this->renderNewValue());
+  }
+
+  public function validateTransactions($object, array $xactions) {
+    $errors = array();
+
+    if ($this->isEmptyTextTransaction($object->getAlias(), $xactions)) {
+      $errors[] = $this->newRequiredError(
+        pht('Phurls must have an alias.'));
+    }
+
+    $max_length = $object->getColumnMaximumByteLength('alias');
+    foreach ($xactions as $xaction) {
+      $new_alias = $xaction->getNewValue();
+
+      // Check length
+      $new_length = strlen($new_alias);
+      if ($new_length > $max_length) {
+        $errors[] = $this->newRequiredError(
+          pht('The alias can be no longer than %d characters.', $max_length));
+      }
+
+      // Check characters
+      if ($xaction->getOldValue() != $xaction->getNewValue()) {
+        $debug_alias = new PHUIInvisibleCharacterView($new_alias);
+        if (!preg_match('/[a-zA-Z]/', $new_alias)) {
+          $errors[] = $this->newRequiredError(
+            pht('The alias you provided (%s) must contain at least one '.
+              'letter.',
+              $debug_alias));
+        }
+        if (preg_match('/[^a-z0-9]/i', $new_alias)) {
+          $errors[] = $this->newRequiredError(
+            pht('The alias you provided (%s) may only contain letters and '.
+              'numbers.',
+              $debug_alias));
+        }
+      }
+    }
+
+    return $errors;
+  }
+
+  public function getIcon() {
+    return 'fa-compress';
+  }
+
+}
diff --git a/src/applications/phurl/xaction/PhabricatorPhurlURLDescriptionTransaction.php b/src/applications/phurl/xaction/PhabricatorPhurlURLDescriptionTransaction.php
new file mode 100644
--- /dev/null
+++ b/src/applications/phurl/xaction/PhabricatorPhurlURLDescriptionTransaction.php
@@ -0,0 +1,60 @@
+<?php
+
+final class PhabricatorPhurlURLDescriptionTransaction
+  extends PhabricatorPhurlURLTransactionType {
+
+  const TRANSACTIONTYPE = 'phurl.description';
+
+  public function generateOldValue($object) {
+    return $object->getDescription();
+  }
+
+  public function applyInternalEffects($object, $value) {
+    $object->setDescription($value);
+  }
+
+  public function getTitle() {
+    return pht(
+      '%s updated the description.',
+      $this->renderAuthor());
+  }
+
+  public function getTitleForFeed() {
+    return pht(
+      '%s updated the description for %s.',
+      $this->renderAuthor(),
+      $this->renderObject());
+  }
+
+  public function hasChangeDetailView() {
+    return true;
+  }
+
+  public function getMailDiffSectionHeader() {
+    return pht('CHANGES TO PHURL DESCRIPTION');
+  }
+
+  public function newChangeDetailView() {
+    $viewer = $this->getViewer();
+
+    return id(new PhabricatorApplicationTransactionTextDiffDetailView())
+      ->setViewer($viewer)
+      ->setOldText($this->getOldValue())
+      ->setNewText($this->getNewValue());
+  }
+
+  public function newRemarkupChanges() {
+    $changes = array();
+
+    $changes[] = $this->newRemarkupChange()
+      ->setOldValue($this->getOldValue())
+      ->setNewValue($this->getNewValue());
+
+    return $changes;
+  }
+
+  public function getIcon() {
+    return 'fa-file-text-o';
+  }
+
+}
diff --git a/src/applications/phurl/xaction/PhabricatorPhurlURLLongURLTransaction.php b/src/applications/phurl/xaction/PhabricatorPhurlURLLongURLTransaction.php
new file mode 100644
--- /dev/null
+++ b/src/applications/phurl/xaction/PhabricatorPhurlURLLongURLTransaction.php
@@ -0,0 +1,59 @@
+<?php
+
+final class PhabricatorPhurlURLLongURLTransaction
+  extends PhabricatorPhurlURLTransactionType {
+
+  const TRANSACTIONTYPE = 'phurl.longurl';
+
+  public function generateOldValue($object) {
+    return $object->getLongURL();
+  }
+
+  public function applyInternalEffects($object, $value) {
+    $object->setLongURL($value);
+  }
+
+  public function getTitle() {
+    return pht(
+      '%s changed the destination URL from %s to %s.',
+      $this->renderAuthor(),
+      $this->renderOldValue(),
+      $this->renderNewValue());
+  }
+
+  public function getTitleForFeed() {
+    return pht(
+      '%s changed the destination URL %s from %s to %s.',
+      $this->renderAuthor(),
+      $this->renderObject(),
+      $this->renderOldValue(),
+      $this->renderNewValue());
+  }
+
+  public function validateTransactions($object, array $xactions) {
+    $errors = array();
+
+    if ($this->isEmptyTextTransaction($object->getLongURL(), $xactions)) {
+      $errors[] = $this->newRequiredError(
+        pht('URL path is required'));
+    }
+
+    foreach ($xactions as $xaction) {
+      if ($xaction->getOldValue() != $xaction->getNewValue()) {
+        $protocols = PhabricatorEnv::getEnvConfig('uri.allowed-protocols');
+        $uri = new PhutilURI($xaction->getNewValue());
+        if (!isset($protocols[$uri->getProtocol()])) {
+          $errors[] = $this->newRequiredError(
+            pht('The protocol of the URL is invalid.'));
+        }
+      }
+    }
+
+    return $errors;
+  }
+
+  public function getIcon() {
+    return 'fa-external-link-square';
+  }
+
+}
diff --git a/src/applications/phurl/xaction/PhabricatorPhurlURLNameTransaction.php b/src/applications/phurl/xaction/PhabricatorPhurlURLNameTransaction.php
new file mode 100644
--- /dev/null
+++ b/src/applications/phurl/xaction/PhabricatorPhurlURLNameTransaction.php
@@ -0,0 +1,44 @@
+<?php
+
+final class PhabricatorPhurlURLNameTransaction
+  extends PhabricatorPhurlURLTransactionType {
+
+  const TRANSACTIONTYPE = 'phurl.name';
+
+  public function generateOldValue($object) {
+    return $object->getName();
+  }
+
+  public function applyInternalEffects($object, $value) {
+    $object->setName($value);
+  }
+
+  public function getTitle() {
+    return pht(
+      '%s changed the name of the URL from %s to %s.',
+      $this->renderAuthor(),
+      $this->renderOldValue(),
+      $this->renderNewValue());
+  }
+
+  public function getTitleForFeed() {
+    return pht(
+      '%s changed the name of %s from %s to %s.',
+      $this->renderAuthor(),
+      $this->renderObject(),
+      $this->renderOldValue(),
+      $this->renderNewValue());
+  }
+
+  public function validateTransactions($object, array $xactions) {
+    $errors = array();
+
+    if ($this->isEmptyTextTransaction($object->getName(), $xactions)) {
+      $errors[] = $this->newRequiredError(
+        pht('Phurls must have a name.'));
+    }
+
+    return $errors;
+  }
+
+}
diff --git a/src/applications/phurl/xaction/PhabricatorPhurlURLTransactionType.php b/src/applications/phurl/xaction/PhabricatorPhurlURLTransactionType.php
new file mode 100644
--- /dev/null
+++ b/src/applications/phurl/xaction/PhabricatorPhurlURLTransactionType.php
@@ -0,0 +1,4 @@
+<?php
+
+abstract class PhabricatorPhurlURLTransactionType
+  extends PhabricatorModularTransactionType {}
diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
--- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
+++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
@@ -2324,51 +2324,6 @@
     return true;
   }
 
-  /**
-   * Check that text field input isn't longer than a specified length.
-   *
-   * A text field input is invalid if the length of the input is longer than a
-   * specified length. This length can be determined by the space allotted in
-   * the database, or given arbitrarily.
-   * This method is intended to make implementing @{method:validateTransaction}
-   * more convenient:
-   *
-   *   $overdrawn = $this->validateIsTextFieldTooLong(
-   *     $object->getName(),
-   *     $xactions,
-   *     $field_length);
-   *
-   * This will return `true` if the net effect of the object and transactions
-   * is a field that is too long.
-   *
-   * @param wild Current field value.
-   * @param list<PhabricatorApplicationTransaction> Transactions editing the
-   *          field.
-   * @param integer for maximum field length.
-   * @return bool True if the field will be too long after edits.
-   */
-  protected function validateIsTextFieldTooLong(
-    $field_value,
-    array $xactions,
-    $length) {
-
-    if ($xactions) {
-      $new_value_length = phutil_utf8_strlen(last($xactions)->getNewValue());
-      if ($new_value_length <= $length) {
-        return false;
-      } else {
-        return true;
-      }
-    }
-
-    $old_value_length = phutil_utf8_strlen($field_value);
-    if ($old_value_length <= $length) {
-      return false;
-    }
-
-    return true;
-  }
-
 
 /* -(  Implicit CCs  )------------------------------------------------------- */