diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py
index 653530a32e306c1c556cc7e680eb2cab2f837b56..3dd42e5c89dcaa26d0c5c931b08d98b314830696 100644
--- a/opentech/apply/activity/messaging.py
+++ b/opentech/apply/activity/messaging.py
@@ -218,6 +218,7 @@ class ActivityAdapter(AdapterBase):
         MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {source}',
         MESSAGES.CREATED_PROJECT: '{user} has created Project',
         MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from from {old_lead} to {source.lead} by {user}',
+        MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval on Project',
     }
 
     def recipients(self, message_type, **kwargs):
@@ -379,6 +380,7 @@ class SlackAdapter(AdapterBase):
         MESSAGES.CREATED_PROJECT: '{user} has created a Project: <{link}|{source.title}>.',
         MESSAGES.UPDATE_PROJECT_LEAD: 'The lead of project <{link}|{source.title}> has been updated from {old_lead} to {source.lead} by {user}',
         MESSAGES.EDIT_REVIEW: '{user} has edited {review.author} review for <{link}|{source.title}>.',
+        MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval on project <{link}|{source.title}>.',
     }
 
     def __init__(self):
@@ -407,6 +409,13 @@ class SlackAdapter(AdapterBase):
         }
 
     def recipients(self, message_type, source, related, **kwargs):
+        if message_type == MESSAGES.SEND_FOR_APPROVAL:
+            return [
+                self.slack_id(user)
+                for slack_id in User.objects.approvers()
+                if self.slack_id(user)
+            ]
+
         recipients = [self.slack_id(source.lead)]
 
         # Notify second reviewer when first reviewer is done.
diff --git a/opentech/apply/activity/migrations/0034_add_send_for_approval.py b/opentech/apply/activity/migrations/0034_add_send_for_approval.py
new file mode 100644
index 0000000000000000000000000000000000000000..4dcb14b42ac373f6837dca31c7a84ec74b1ef01d
--- /dev/null
+++ b/opentech/apply/activity/migrations/0034_add_send_for_approval.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-05 16:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0033_remove_old_submission_fk_event'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py
index 969eee9c5670ad0670215a67369d8e959b3dc9bd..8e253c8b6dc21c254f74e9679a1ed86302e1594d 100644
--- a/opentech/apply/activity/options.py
+++ b/opentech/apply/activity/options.py
@@ -29,6 +29,7 @@ class MESSAGES(Enum):
     CREATED_PROJECT = 'Created Project'
     UPDATE_PROJECT_LEAD = 'Update Project Lead'
     EDIT_REVIEW = 'Edit Review'
+    SEND_FOR_APPROVAL = 'Send for Approval'
 
     @classmethod
     def choices(cls):
diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py
index c49e67095f2407113ed3f157c611d7efd08183dc..cb18266afe9609f5ce454342ebe8c890ed3df5b7 100644
--- a/opentech/apply/projects/forms.py
+++ b/opentech/apply/projects/forms.py
@@ -5,7 +5,7 @@ from addressfield.fields import AddressField
 from opentech.apply.funds.models import ApplicationSubmission
 from opentech.apply.users.groups import STAFF_GROUP_NAME
 
-from .models import Project
+from .models import Project, COMMITTED
 
 
 class CreateProjectForm(forms.Form):
@@ -54,6 +54,29 @@ class ProjectEditForm(forms.ModelForm):
         return super().save(*args, **kwargs)
 
 
+class SetPendingForm(forms.ModelForm):
+    class Meta:
+        fields = ['id']
+        model = Project
+        widgets = {'id': forms.HiddenInput()}
+
+    def __init__(self, user=None, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def clean(self):
+        if self.instance.status != COMMITTED:
+            raise forms.ValidationError('A Project can only be sent for Approval when Committed.')
+
+        if self.instance.is_locked:
+            raise forms.ValidationError('A Project can only be sent for Approval once')
+
+        super().clean()
+
+    def save(self, *args, **kwargs):
+        self.instance.is_locked = True
+        return super().save(*args, **kwargs)
+
+
 class UpdateProjectLeadForm(forms.ModelForm):
     class Meta:
         fields = ['lead']
diff --git a/opentech/apply/projects/migrations/0009_add_approval.py b/opentech/apply/projects/migrations/0009_add_approval.py
new file mode 100644
index 0000000000000000000000000000000000000000..92b0c41572e2d1549f53767d3da3dcff0b4424ae
--- /dev/null
+++ b/opentech/apply/projects/migrations/0009_add_approval.py
@@ -0,0 +1,50 @@
+# Generated by Django 2.0.13 on 2019-08-06 09:30
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('application_projects', '0009_documentcategory'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Approval',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='created_at',
+            field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='is_locked',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='status',
+            field=models.TextField(choices=[('committed', 'Committed'), ('contracting', 'Contracting'), ('in_progress', 'In Progress'), ('closing', 'Closing'), ('complete', 'Complete')], default='committed'),
+        ),
+        migrations.AddField(
+            model_name='approval',
+            name='project',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='application_projects.Project'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='approval',
+            unique_together={('project', 'by')},
+        ),
+    ]
diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py
index 7d8364d0776b5a4df465124a350589cf113326b4..6c771c1e44fb43108d44c530fdd94c2abcc1d2d0 100644
--- a/opentech/apply/projects/models.py
+++ b/opentech/apply/projects/models.py
@@ -9,6 +9,29 @@ from django.urls import reverse
 from django.utils.translation import ugettext as _
 
 
+class Approval(models.Model):
+    project = models.ForeignKey("Project", on_delete=models.CASCADE)
+    by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+
+    created_at = models.DateTimeField(auto_now_add=True)
+
+    class Meta:
+        unique_together = ['project', 'by']
+
+    def __str__(self):
+        return f'Approval of "{self.project.title}" by {self.by}'
+
+
+COMMITTED = 'committed'
+PROJECT_STATUS_CHOICES = [
+    (COMMITTED, 'Committed'),
+    ('contracting', 'Contracting'),
+    ('in_progress', 'In Progress'),
+    ('closing', 'Closing'),
+    ('complete', 'Complete'),
+]
+
+
 class Project(models.Model):
     lead = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='lead_projects')
     submission = models.OneToOneField("funds.ApplicationSubmission", on_delete=models.CASCADE)
@@ -29,6 +52,11 @@ class Project(models.Model):
     proposed_start = models.DateTimeField(_('Proposed Start Date'), null=True)
     proposed_end = models.DateTimeField(_('Proposed End Date'), null=True)
 
+    status = models.TextField(choices=PROJECT_STATUS_CHOICES, default=COMMITTED)
+
+    # tracks read/write state of the Project
+    is_locked = models.BooleanField(default=False)
+
     # tracks updates to the Projects fields via the Project Application Form.
     user_has_updated_details = models.BooleanField(default=False)
 
@@ -38,6 +66,7 @@ class Project(models.Model):
         object_id_field='source_object_id',
         related_query_name='project',
     )
+    created_at = models.DateTimeField(auto_now_add=True)
 
     def __str__(self):
         return self.title
@@ -79,6 +108,17 @@ class Project(models.Model):
     def get_absolute_url(self):
         return reverse('apply:projects:detail', args=[self.id])
 
+    @property
+    def is_pending_approval(self):
+        """
+        Wrapper to expose the pending approval state
+
+        We don't want to expose a "Sent for Approval" state to the end User so
+        we infer it from the current status being "Comitted" and the Project
+        being locked.
+        """
+        return self.status == COMMITTED and self.is_locked
+
 
 class DocumentCategory(models.Model):
     name = models.CharField(max_length=254)
diff --git a/opentech/apply/projects/templates/application_projects/project_admin_detail.html b/opentech/apply/projects/templates/application_projects/project_admin_detail.html
index b8247d6c88077a1adadf792f8f63d551211876e0..105842c32f20d10a7be7814b592c2ab5890f2f4a 100644
--- a/opentech/apply/projects/templates/application_projects/project_admin_detail.html
+++ b/opentech/apply/projects/templates/application_projects/project_admin_detail.html
@@ -3,6 +3,11 @@
 {% load static %}
 
 {% block admin_sidebar %}
+<div class="modal" id="send-for-approval">
+    <h4 class="modal__header-bar">Request Approval</h4>
+    {% include 'funds/includes/delegated_form_base.html' with form=approval_form value='Request'%}
+</div>
+
 <div class="modal" id="assign-lead">
     <h4 class="modal__header-bar">Assign Lead</h4>
     {% include 'funds/includes/delegated_form_base.html' with form=lead_form value='Update'%}
@@ -16,12 +21,14 @@
 
     <h5>Actions to take</h5>
 
+    {% if not object.is_pending_approval %}
     <a data-fancybox
        data-src="#send-for-approval"
        class="button button--bottom-space button--primary button--full-width"
        href="#">
         Send for approval
     </a>
+    {% endif %}
 
     <a data-fancybox
        data-src="#ready-for-contracting"
diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py
index 2bb01a348ff80d3622941bce885647e4f235eab3..b6eda3f39af5a0e6255dd4e7d7c9dfc1e8ca2ad3 100644
--- a/opentech/apply/projects/tests/test_views.py
+++ b/opentech/apply/projects/tests/test_views.py
@@ -4,7 +4,9 @@ from opentech.apply.users.tests.factories import (ReviewerFactory,
                                                   StaffFactory,
                                                   SuperUserFactory,
                                                   UserFactory)
+from opentech.apply.utils.testing.tests import BaseViewTestCase
 
+from ..forms import SetPendingForm
 from ..views import ProjectDetailView
 from .factories import ProjectFactory
 
@@ -41,3 +43,39 @@ class TestProjectDetailView(TestCase):
 
         response = ProjectDetailView.as_view()(request, pk=self.project.pk)
         self.assertEqual(response.status_code, 200)
+
+
+class TestSendForApprovalView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_send_for_approval_fails_when_project_is_locked(self):
+        project = ProjectFactory(is_locked=True)
+
+        # The view doesn't have any custom changes when form validation fails
+        # so check that directly.
+        form = SetPendingForm(instance=project)
+        self.assertFalse(form.is_valid())
+
+    def test_send_for_approval_fails_when_project_is_not_in_committed_state(self):
+        project = ProjectFactory(status='in_progress')
+
+        # The view doesn't have any custom changes when form validation fails
+        # so check that directly.
+        form = SetPendingForm(instance=project)
+        self.assertFalse(form.is_valid())
+
+    def test_send_for_approval_happy_path(self):
+        project = ProjectFactory(is_locked=False, status='committed')
+
+        response = self.post_page(project, {'form-submitted-approval_form': ''})
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+
+        self.assertTrue(project.is_locked)
+        self.assertEqual(project.status, 'committed')
diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views.py
index 63e88bc5d182d3a1773cab7b9f94e68337f25e11..a0b6b86671d8486dc945dcc06fdeb79195dcbbee 100644
--- a/opentech/apply/projects/views.py
+++ b/opentech/apply/projects/views.py
@@ -9,8 +9,29 @@ from opentech.apply.users.decorators import staff_required
 from opentech.apply.utils.views import (DelegateableView, DelegatedViewMixin,
                                         ViewDispatcher)
 
-from .forms import ProjectEditForm, UpdateProjectLeadForm
-from .models import Project, DocumentCategory
+from .forms import ProjectEditForm, SetPendingForm, UpdateProjectLeadForm
+from .models import DocumentCategory, Project
+
+
+@method_decorator(staff_required, name='dispatch')
+class SendForApprovalView(DelegatedViewMixin, UpdateView):
+    context_name = 'approval_form'
+    form_class = SetPendingForm
+    model = Project
+
+    def form_valid(self, form):
+        # lock project
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.SEND_FOR_APPROVAL,
+            request=self.request,
+            user=self.request.user,
+            source=form.instance.submission,
+            project=form.instance,
+        )
+
+        return response
 
 
 @method_decorator(staff_required, name='dispatch')
@@ -38,6 +59,7 @@ class UpdateLeadView(DelegatedViewMixin, UpdateView):
 
 class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView):
     form_views = [
+        SendForApprovalView,
         UpdateLeadView,
         CommentFormView,
     ]
diff --git a/opentech/apply/users/groups.py b/opentech/apply/users/groups.py
index cc9cad3806bf3e4bf2dd4a435da1170dcbb10c0a..0fb0a9f0bff52c703b3e7e42506034fca497784d 100644
--- a/opentech/apply/users/groups.py
+++ b/opentech/apply/users/groups.py
@@ -4,6 +4,7 @@ REVIEWER_GROUP_NAME = 'Reviewer'
 TEAMADMIN_GROUP_NAME = 'Team Admin'
 PARTNER_GROUP_NAME = 'Partner'
 COMMUNITY_REVIEWER_GROUP_NAME = 'Community reviewer'
+APPROVER_GROUP_NAME = 'Approver'
 
 GROUPS = [
     {
@@ -30,4 +31,8 @@ GROUPS = [
         'name': COMMUNITY_REVIEWER_GROUP_NAME,
         'permissions': [],
     },
+    {
+        'name': APPROVER_GROUP_NAME,
+        'permissions': [],
+    }
 ]
diff --git a/opentech/apply/users/migrations/0013_add_approver_group.py b/opentech/apply/users/migrations/0013_add_approver_group.py
new file mode 100644
index 0000000000000000000000000000000000000000..54f9f968f7acf0efcbb62232970719a4ac83517f
--- /dev/null
+++ b/opentech/apply/users/migrations/0013_add_approver_group.py
@@ -0,0 +1,43 @@
+# Generated by Django 2.0.13 on 2019-08-05 13:12
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.core.management.sql import emit_post_migrate_signal
+from django.db import migrations
+
+from opentech.apply.users.groups import APPROVER_GROUP_NAME, GROUPS
+
+
+def add_groups(apps, schema_editor):
+    # Workaround for https://code.djangoproject.com/ticket/23422
+    db_alias = schema_editor.connection.alias
+    emit_post_migrate_signal(2, False, db_alias)
+
+    Group = apps.get_model('auth.Group')
+    Permission = apps.get_model('auth.Permission')
+
+    for group_data in GROUPS:
+        group, created = Group.objects.get_or_create(name=group_data['name'])
+        for codename in group_data['permissions']:
+            try:
+                permission = Permission.objects.get(codename=codename)
+            except ObjectDoesNotExist:
+                print(f"Could not find the '{permission}' permission")
+                continue
+
+            group.permissions.add(permission)
+
+
+def remove_groups(apps, schema_editor):
+    Group = apps.get_model('auth.Group')
+    Group.objects.filter(name=APPROVER_GROUP_NAME).delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0012_set_applicant_group'),
+    ]
+
+    operations = [
+        migrations.RunPython(add_groups, remove_groups)
+    ]
diff --git a/opentech/apply/users/models.py b/opentech/apply/users/models.py
index 9609eb97e1ebfdf0d7b2b5b866f828088fba333b..768b583c958865dcd2851c39fa5ffeb686d3845b 100644
--- a/opentech/apply/users/models.py
+++ b/opentech/apply/users/models.py
@@ -1,11 +1,14 @@
-from django.db import models
-from django.db.models import Q
 from django.contrib.auth.hashers import make_password
 from django.contrib.auth.models import AbstractUser, BaseUserManager, Group
+from django.db import models
+from django.db.models import Q
 from django.urls import reverse
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
-from .groups import APPLICANT_GROUP_NAME, REVIEWER_GROUP_NAME, STAFF_GROUP_NAME, PARTNER_GROUP_NAME, COMMUNITY_REVIEWER_GROUP_NAME
+
+from .groups import (APPLICANT_GROUP_NAME, APPROVER_GROUP_NAME,
+                     COMMUNITY_REVIEWER_GROUP_NAME, PARTNER_GROUP_NAME,
+                     REVIEWER_GROUP_NAME, STAFF_GROUP_NAME)
 from .utils import send_activation_email
 
 
@@ -27,6 +30,9 @@ class UserQuerySet(models.QuerySet):
     def applicants(self):
         return self.filter(groups__name=APPLICANT_GROUP_NAME)
 
+    def approvers(self):
+        return self.filter(groups__name=APPROVER_GROUP_NAME)
+
 
 class UserManager(BaseUserManager.from_queryset(UserQuerySet)):
     use_in_migrations = True
@@ -132,6 +138,10 @@ class User(AbstractUser):
     def is_applicant(self):
         return self.groups.filter(name=APPLICANT_GROUP_NAME).exists()
 
+    @cached_property
+    def is_approver(self):
+        return self.groups.filter(name=APPROVER_GROUP_NAME).exists()
+
     class Meta:
         ordering = ('full_name', 'email')