diff --git a/Forms/DefaultFormFactory.php b/Forms/DefaultFormFactory.php
new file mode 100644
index 000000000..c7b6e0889
--- /dev/null
+++ b/Forms/DefaultFormFactory.php
@@ -0,0 +1,108 @@
+constructExtensions();
+ }
+
+ public function getForm(Controller $controller, $name = FormFactory::DEFAULT_NAME, $context = [])
+ {
+ // Validate context
+ foreach($this->getRequiredContext() as $required) {
+ if (!isset($context[$required])) {
+ throw new InvalidArgumentException("Missing required context $required");
+ }
+ }
+
+ $fields = $this->getFormFields($controller, $name, $context);
+ $actions = $this->getFormActions($controller, $name, $context);
+ $validator = $this->getFormValidator($controller, $name, $context);
+ $form = Form::create($controller, $name, $fields, $actions, $validator);
+
+ // Extend form
+ $this->invokeWithExtensions('updateForm', $form, $controller, $name, $context);
+
+ // Populate form from record
+ $form->loadDataFrom($context['Record']);
+
+ return $form;
+ }
+
+ /**
+ * Build field list for this form
+ *
+ * @param Controller $controller
+ * @param string $name
+ * @param array $context
+ * @return FieldList
+ */
+ protected function getFormFields(Controller $controller, $name, $context = []) {
+ // Fall back to standard "getCMSFields" which itself uses the FormScaffolder as a fallback
+ // @todo Deprecate or formalise support for getCMSFields()
+ $fields = $context['Record']->getCMSFields();
+ $this->invokeWithExtensions('updateFormFields', $fields, $controller, $name, $context);
+ return $fields;
+ }
+
+ /**
+ * Build list of actions for this form
+ *
+ * @param Controller $controller
+ * @param string $name
+ * @param array $context
+ * @return FieldList
+ */
+ protected function getFormActions(Controller $controller, $name, $context = []) {
+ // @todo Deprecate or formalise support for getCMSActions()
+ $actions = $context['Record']->getCMSActions();
+ $this->invokeWithExtensions('updateFormActions', $actions, $controller, $name, $context);
+ return $actions;
+ }
+
+ /**
+ * @param Controller $controller
+ * @param string $name
+ * @param array $context
+ * @return null|Validator
+ */
+ protected function getFormValidator(Controller $controller, $name, $context = []) {
+ $validator = null;
+ if ($context['Record']->hasMethod('getCMSValidator')) {
+ // @todo Deprecate or formalise support for getCMSValidator()
+ $validator = $context['Record']->getCMSValidator();
+ }
+
+ // Extend validator
+ $this->invokeWithExtensions('updateFormValidator', $validator, $controller, $name, $context);
+ return $validator;
+ }
+
+ /**
+ * Return list of mandatory context keys
+ *
+ * @return mixed
+ */
+ public function getRequiredContext()
+ {
+ return ['Record'];
+ }
+}
diff --git a/Forms/FormFactory.php b/Forms/FormFactory.php
new file mode 100644
index 000000000..b056cd36e
--- /dev/null
+++ b/Forms/FormFactory.php
@@ -0,0 +1,35 @@
+Form();
+
+ // Check formfields
+ $this->assertInstanceOf(TextField::class, $form->Fields()->fieldByName('Title'));
+ $this->assertInstanceOf(HiddenField::class, $form->Fields()->fieldByName('ID'));
+ $this->assertInstanceOf(HiddenField::class, $form->Fields()->fieldByName('SecurityID'));
+
+
+ // Check preview link
+ /** @var LiteralField $previewLink */
+ $previewLink = $form->Fields()->fieldByName('PreviewLink');
+ $this->assertInstanceOf(LiteralField::class, $previewLink);
+ $this->assertEquals(
+ 'Preview',
+ $previewLink->getContent()
+ );
+
+ // Check actions
+ $this->assertInstanceOf(FormAction::class, $form->Actions()->fieldByName('action_save'));
+ $this->assertInstanceOf(FormAction::class, $form->Actions()->fieldByName('action_publish'));
+ $this->assertTrue($controller->hasAction('publish'));
+ }
+
+ /**
+ * Removing versioning from an object should result in a simpler form
+ */
+ public function testBasicForm() {
+ FormFactoryTest_TestObject::remove_extension(Versioned::class);
+ $controller = new FormFactoryTest_TestController();
+ $form = $controller->Form();
+
+ // Check formfields
+ $this->assertInstanceOf(TextField::class, $form->Fields()->fieldByName('Title'));
+ $this->assertNull($form->Fields()->fieldByName('PreviewLink'));
+
+ // Check actions
+ $this->assertInstanceOf(FormAction::class, $form->Actions()->fieldByName('action_save'));
+ $this->assertNull($form->Actions()->fieldByName('action_publish'));
+ }
+}
+
+/**
+ * @mixin Versioned
+ */
+class FormFactoryTest_TestObject extends DataObject {
+ private static $db = [
+ 'Title' => 'Varchar',
+ ];
+
+ private static $extensions = [
+ Versioned::class,
+ ];
+}
+
+/**
+ * Edit controller for this form
+ */
+class FormFactoryTest_TestController extends Controller {
+ private static $extensions = [
+ FormFactoryTest_ControllerExtension::class,
+ ];
+
+ public function Link($action = null) {
+ return Controller::join_links('FormFactoryTest_TestController', $action, '/');
+ }
+
+ public function Form() {
+ // Simple example; Just get the first draft record
+ $record = $this->getRecord();
+ $factory = new FormFactoryTest_EditFactory();
+ return $factory->getForm($this, 'Form', ['Record' => $record]);
+ }
+
+ public function save($data, Form $form) {
+ // Noop
+ }
+
+ /**
+ * @return DataObject
+ */
+ protected function getRecord()
+ {
+ return Versioned::get_by_stage(FormFactoryTest_TestObject::class, Versioned::DRAFT)->first();
+ }
+}
+
+/**
+ * Provides versionable extensions to a controller / scaffolder
+ */
+class FormFactoryTest_ControllerExtension extends Extension {
+
+ /**
+ * Handlers for extra actions added by this extension
+ *
+ * @var array
+ */
+ private static $allowed_actions = [
+ 'publish',
+ 'preview',
+ ];
+
+ /**
+ * Adds additional form actions
+ *
+ * @param FieldList $actions
+ * @param Controller $controller
+ * @param string $name
+ * @param array $context
+ */
+ public function updateFormActions(FieldList &$actions, Controller $controller, $name, $context = []) {
+ // Add publish button if record is versioned
+ if (empty($context['Record'])) {
+ return;
+ }
+ $record = $context['Record'];
+ if ($record->hasExtension(Versioned::class)) {
+ $actions->push(new FormAction('publish', 'Publish'));
+ }
+ }
+
+ /**
+ * Adds extra fields to this form
+ *
+ * @param FieldList $fields
+ * @param Controller $controller
+ * @param string $name
+ * @param array $context
+ */
+ public function updateFormFields(FieldList &$fields, Controller $controller, $name, $context = []) {
+ // Add preview link
+ if (empty($context['Record'])) {
+ return;
+ }
+ $record = $context['Record'];
+ if ($record->hasExtension(Versioned::class)) {
+ $link = $controller->Link('preview');
+ $fields->unshift(new LiteralField(
+ "PreviewLink",
+ sprintf('Preview', Convert::raw2att($link))
+ ));
+ }
+ }
+
+ public function publish($data, $form) {
+ // noop
+ }
+
+ public function preview() {
+ // noop
+ }
+}
+
+/**
+ * Test factory
+ */
+class FormFactoryTest_EditFactory extends DefaultFormFactory {
+
+ private static $extensions = [
+ FormFactoryTest_ControllerExtension::class
+ ];
+
+ protected function getFormFields(Controller $controller, $name, $context = [])
+ {
+ $fields = new FieldList(
+ new HiddenField('ID'),
+ new TextField('Title')
+ );
+ $this->invokeWithExtensions('updateFormFields', $fields, $controller, $name, $context);
+ return $fields;
+ }
+
+ protected function getFormActions(Controller $controller, $name, $context = [])
+ {
+ $actions = new FieldList(
+ new FormAction('save', 'Save')
+ );
+ $this->invokeWithExtensions('updateFormActions', $actions, $controller, $name, $context);
+ return $actions;
+ }
+}
diff --git a/tests/forms/FormFactoryTest.yml b/tests/forms/FormFactoryTest.yml
new file mode 100644
index 000000000..a3808f4a0
--- /dev/null
+++ b/tests/forms/FormFactoryTest.yml
@@ -0,0 +1,3 @@
+FormFactoryTest_TestObject:
+ object:
+ Title: 'Test Object'