chrisbaltazar/doctrine-soft-delete
Install the Bundle:
composer require chrisbaltazar/doctrine-soft-delete
Ensure Database\SoftDelete\SoftDeleteBundle::class is registered in config/bundles.php (handled automatically by Symfony Flex).
Mark an Entity for Soft Deletes:
Implement SoftDeletableInterface and add a deletedAt column to your entity:
use Database\SoftDelete\Core\Contract\SoftDeletableInterface;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Product implements SoftDeletableInterface
{
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
public function setDeletedAt(\DateTimeImmutable $deletedAt): void { $this->deletedAt = $deletedAt; }
public function getDeletedAt(): ?\DateTimeImmutable { return $this->deletedAt; }
}
Soft-Delete a Record:
$product = $entityManager->find(Product::class, 1);
$product->setDeletedAt(new \DateTimeImmutable());
$entityManager->flush();
Verify Filtering:
All queries on Product will automatically exclude soft-deleted records. No manual WHERE clauses needed.
Entity Setup:
#[ORM\Entity]
#[SoftDeleteUniqueIndex(fields: ['email'])] // Ensures email uniqueness ignores soft-deleted users
class User implements SoftDeletableInterface
{
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
// ... other fields
}
Delete User (Compliant with "Right to Erasure"):
$user->setDeletedAt(new \DateTimeImmutable());
$entityManager->flush();
Temporarily Include Deleted Users (e.g., for admin audit):
$entityManager->getFilters()->disable('soft_delete');
$deletedUsers = $entityManager->getRepository(User::class)->findAll();
$entityManager->getFilters()->enable('soft_delete'); // Re-enable
README.md for installation and basic usage.SoftDeletableInterface and #[SoftDeleteUniqueIndex] for advanced features.$entityManager->getFilters() works for temporary overrides.make demo-run to see the bundle in action (requires Docker).Entity Design:
SoftDeletableInterface for all soft-deletable entities.deletedAt column (timestamp type).#[SoftDeleteUniqueIndex] for fields requiring uniqueness (e.g., email).Deletion Logic:
// Service/Controller
public function softDelete(Entity $entity): void
{
$entity->setDeletedAt(new \DateTimeImmutable());
$this->entityManager->flush();
}
Query Handling:
WHERE deletedAt IS NULL to every query.Bulk Operations:
// Soft-delete all inactive users
$inactiveUsers = $entityManager->createQueryBuilder()
->select('u')
->from(User::class, 'u')
->where('u.isActive = :active')
->setParameter('active', false)
->getQuery()
->getResult();
foreach ($inactiveUsers as $user) {
$user->setDeletedAt(new \DateTimeImmutable());
}
$entityManager->flush();
Use cases: Admin dashboards, audit logs, or data exports requiring all records.
// Controller/Service
public function exportAllUsers(): void
{
$entityManager->getFilters()->disable('soft_delete');
$users = $entityManager->getRepository(User::class)->findAll();
// Process data...
$entityManager->getFilters()->enable('soft_delete');
}
Prevents duplicate email entries even after soft deletes:
#[ORM\Entity]
#[SoftDeleteUniqueIndex(fields: ['email'])]
class User implements SoftDeletableInterface
{
// ...
}
php bin/console doctrine:migrations:diff and apply the migration.try {
$entityManager->persist($user);
$entityManager->flush();
} catch (UniqueConstraintViolationException $e) {
$this->addFlash('error', 'Email already exists (ignoring soft-deleted users).');
}
Extend the interface or use traits for domain-specific behavior:
trait SoftDeletableWithReason
{
#[ORM\Column(nullable: true)]
private ?string $deletedReason = null;
public function setDeletedAtWithReason(\DateTimeImmutable $deletedAt, string $reason): void
{
$this->deletedAt = $deletedAt;
$this->deletedReason = $reason;
}
}
Add a "Soft Delete" checkbox to forms:
// UserType.php
$builder->add('softDelete', CheckboxType::class, [
'label' => 'Soft Delete (Archive)',
'mapped' => false,
'required' => false,
]);
// Controller
public function update(Request $request, User $user): Response
{
$form = $this->createForm(UserType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($request->request->get('soft_delete')) {
$user->setDeletedAt(new \DateTimeImmutable());
}
$this->entityManager->flush();
}
// ...
}
Trigger actions when an entity is soft-deleted (e.g., log to an audit table):
// src/EventListener/SoftDeleteListener.php
use Doctrine\ORM\Event\LifecycleEventArgs;
class SoftDeleteListener
{
public function postPersist(LifecycleEventArgs $args): void
{
$entity = $args->getObject();
if ($entity instanceof SoftDeletableInterface && $entity->getDeletedAt() !== null) {
$this->logSoftDelete($entity);
}
}
private function logSoftDelete(object $entity): void
{
// Log to audit table or external service
}
}
Register the listener in services.yaml:
services:
App\EventListener\SoftDeleteListener:
tags:
- { name: doctrine.event_listener, event: postPersist }
- { name: doctrine.event_listener, event: postUpdate }
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
disable('soft_delete') in tests to verify queries include soft-deleted records:
public function testSoftDeleteExclusion(EntityManagerInterface $em)
{
$em->getFilters()->disable('soft_delete');
$deletedUser = $em->getRepository(User::class)->findOneBy(['deletedAt' => $this->deletedAt]);
$this->assertNotNull($deletedUser);
}
deletedAt is indexed for performance:
#[ORM\Column(nullable: true)]
#[ORM\Index(name: 'idx_deleted_at')]
private ?\DateTimeImmutable $deletedAt = null;
$conn = $entityManager->getConnection();
$conn->executeStatement(
'UPDATE user SET deleted_at = NOW() WHERE is_active = 0'
);
For SaaS apps, combine soft deletes with tenant isolation:
#[ORM\Entity]
class User implements SoftDeletableInterface
{
#[ORM\Column]
private int $tenantId;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
}
Query with tenant scope:
$users = $
How can I help you explore Laravel packages today?