Shopware 6.7 introduces exciting capabilities for developers to create sophisticated loyalty and rewards systems. In this technical deep dive, we'll explore how to build a comprehensive custom loyalty points system that integrates seamlessly with the platform's existing architecture.

Architecture Overview

The loyalty points system will leverage Shopware 6.7's modular architecture through custom entities, services, and event listeners. Our implementation will consist of several key components:

  1. Custom Entities for storing loyalty point transactions
  2. Event Listeners to trigger point accumulation
  3. Custom Services for point management and validation
  4. API Endpoints for frontend integration
  5. Admin Interface extensions for point management

Custom Entity Creation

First, we need to define our loyalty point entities using Shopware 6.7's entity system:

<?php declare(strict_types=1);

namespace MyPlugin\Entity;

use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\DateTimeField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\LongTextField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\System\User\UserDefinition;

class LoyaltyPointDefinition extends EntityDefinition
{
    public function getEntityName(): string
    {
        return 'loyalty_point';
    }

    public function getCollectionClass(): string
    {
        return LoyaltyPointCollection::class;
    }

    public function getEntityClass(): string
    {
        return LoyaltyPointEntity::class;
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->setFlags(new Required()),
            new StringField('customer_id', 'customerId'),
            new IntField('points', 'points'),
            new StringField('reason', 'reason'),
            new BoolField('is_active', 'isActive'),
            new DateTimeField('created_at', 'createdAt'),
            new DateTimeField('updated_at', 'updatedAt'),
            new ManyToOneAssociationField('customer', 'customer_id', CustomerDefinition::class, 'id', false),
        ]);
    }
}

Event-Based Point Accumulation

The core functionality relies on event listeners that trigger point calculations based on customer actions:

<?php declare(strict_types=1);

namespace MyPlugin\Subscriber;

use Shopware\Core\Checkout\Order\Event\OrderStateChangeEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class LoyaltyPointSubscriber implements EventSubscriberInterface
{
    private LoyaltyPointService $loyaltyPointService;
    private CustomerRepository $customerRepository;

    public function __construct(
        LoyaltyPointService $loyaltyPointService,
        CustomerRepository $customerRepository
    ) {
        $this->loyaltyPointService = $loyaltyPointService;
        $this->customerRepository = $customerRepository;
    }

    public static function getSubscribedEvents(): array
    {
        return [
            OrderStateChangeEvent::class => 'onOrderCompleted',
        ];
    }

    public function onOrderCompleted(OrderStateChangeEvent $event): void
    {
        if ($event->getOrder()->getStateMachineState()->getTechnicalName() !== 'state_completed') {
            return;
        }

        $orderId = $event->getOrder()->getId();
        $customerId = $event->getOrder()->getCustomerId();
        
        // Calculate points based on order total and customer type
        $points = $this->calculatePoints($event->getOrder());
        
        if ($points > 0) {
            $this->loyaltyPointService->addPoints(
                $customerId,
                $points,
                'Order completed: ' . $orderId
            );
        }
    }

    private function calculatePoints(OrderEntity $order): int
    {
        $total = $order->getAmountTotal();
        $customer = $order->getCustomer();
        
        // Custom point calculation logic
        $basePoints = (int) ($total / 10);
        
        // Special multiplier for VIP customers
        if ($customer && $customer->getGroup() && $customer->getGroup()->getName() === 'VIP') {
            $basePoints *= 2;
        }
        
        return $basePoints;
    }
}

Service Layer Implementation

The service layer handles point operations and business logic:

<?php declare(strict_types=1);

namespace MyPlugin\Service;

use Shopware\Core\Checkout\Customer\CustomerEntity;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;

class LoyaltyPointService
{
    private EntityRepository $loyaltyPointRepository;
    private CustomerRepository $customerRepository;

    public function __construct(
        EntityRepository $loyaltyPointRepository,
        CustomerRepository $customerRepository
    ) {
        $this->loyaltyPointRepository = $loyaltyPointRepository;
        $this->customerRepository = $customerRepository;
    }

    public function addPoints(string $customerId, int $points, string $reason): void
    {
        $data = [
            'customerId' => $customerId,
            'points' => $points,
            'reason' => $reason,
            'isActive' => true,
            'createdAt' => new \DateTime(),
        ];

        $this->loyaltyPointRepository->create([$data], Context::createDefaultContext());
    }

    public function getCustomerPoints(string $customerId): int
    {
        $criteria = new Criteria();
        $criteria->addFilter(new EqualsFilter('customerId', $customerId));
        $criteria->addFilter(new EqualsFilter('isActive', true));
        
        $points = $this->loyaltyPointRepository->search($criteria, Context::createDefaultContext());
        
        return array_sum(
            array_map(fn($point) => $point->getPoints(), $points->getEntities()->toArray())
        );
    }

    public function redeemPoints(string $customerId, int $pointsToRedeem): bool
    {
        $customerPoints = $this->getCustomerPoints($customerId);
        
        if ($customerPoints < $pointsToRedeem) {
            return false;
        }

        // Mark points as redeemed (soft delete or update status)
        $criteria = new Criteria();
        $criteria->addFilter(new EqualsFilter('customerId', $customerId));
        $criteria->addFilter(new EqualsFilter('isActive', true));
        $criteria->setLimit(1);
        $criteria->addSorting(new FieldSorting('createdAt'));

        $points = $this->loyaltyPointRepository->search($criteria, Context::createDefaultContext());
        
        foreach ($points->getEntities() as $pointEntity) {
            $this->loyaltyPointRepository->update([
                [
                    'id' => $pointEntity->getId(),
                    'isActive' => false,
                ]
            ], Context::createDefaultContext());
            
            $pointsToRedeem -= $pointEntity->getPoints();
            
            if ($pointsToRedeem <= 0) {
                break;
            }
        }

        return true;
    }
}

Admin Interface Integration

We'll extend the admin panel to manage loyalty points:

// src/Administration/Resources/app/administration/src/module/my-plugin/component/loyalty-point-list/index.js

import template from './my-loyalty-point-list.html.twig';

Shopware.Component.register('my-loyalty-point-list', {
    template,
    
    props: {
        customerId: {
            type: String,
            required: true
        }
    },
    
    data() {
        return {
            loyaltyPoints: [],
            isLoading: false,
            pagination: {
                page: 1,
                limit: 25,
                total: 0
            }
        };
    },
    
    methods: {
        async loadLoyaltyPoints() {
            this.isLoading = true;
            
            try {
                const response = await this.$http.get(
                    `/api/_action/loyalty-points/${this.customerId}`,
                    {
                        params: {
                            page: this.pagination.page,
                            limit: this.pagination.limit
                        }
                    }
                );
                
                this.loyaltyPoints = response.data.data;
                this.pagination.total = response.data.total;
            } catch (error) {
                // Handle error
            } finally {
                this.isLoading = false;
            }
        },
        
        onPaginationChange(page, limit) {
            this.pagination.page = page;
            this.pagination.limit = limit;
            this.loadLoyaltyPoints();
        }
    },
    
    created() {
        this.loadLoyaltyPoints();
    }
});

API Endpoint Configuration

Create REST endpoints for frontend integration:

<?php declare(strict_types=1);

namespace MyPlugin\Controller;

use Shopware\Core\Framework\Routing\Annotation\RouteScope;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @RouteScope(scopes={"api"})
 */
class LoyaltyPointController extends AbstractController
{
    private LoyaltyPointService $loyaltyPointService;

    public function __construct(LoyaltyPointService $loyaltyPointService)
    {
        $this->loyaltyPointService = $loyaltyPointService;
    }

    /**
     * @Route("/api/v{version}/loyalty-points/{customerId}", name="api.loyalty.points.get", methods={"GET"})
     */
    public function getCustomerPoints(string $customerId, Request $request): JsonResponse
    {
        $points = $this->loyaltyPointService->getCustomerPoints($customerId);
        
        return new JsonResponse([
            'data' => [
                'customerId' => $customerId,
                'points' => $points,
                'timestamp' => time()
            ]
        ]);
    }

    /**
     * @Route("/api/v{version}/loyalty-points/{customerId}/redeem", name="api.loyalty.points.redeem", methods={"POST"})
     */
    public function redeemPoints(string $customerId, Request $request): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        $pointsToRedeem = (int) ($data['points'] ?? 0);
        
        $success = $this->loyaltyPointService->redeemPoints($customerId, $pointsToRedeem);
        
        return new JsonResponse([
            'success' => $success,
            'redeemedPoints' => $pointsToRedeem
        ]);
    }
}

Performance Considerations

For optimal performance in high-traffic scenarios:

  1. Database Indexing: Ensure proper indexing on customer_id and created_at fields
  2. Caching Strategy: Implement Redis caching for frequently accessed point balances
  3. Batch Processing: Use queue workers for bulk point operations
  4. Query Optimization: Limit result sets and use efficient search criteria
// Cache implementation example
public function getCustomerPointsWithCache(string $customerId): int
{
    $cacheKey = "loyalty_points_{$customerId}";
    $cachedPoints = $this->cache->get($cacheKey);
    
    if ($cachedPoints !== null) {
        return $cachedPoints;
    }
    
    $points = $this->getCustomerPoints($customerId);
    $this->cache->set($cacheKey, $points, 3600); // Cache for 1 hour
    
    return $points;
}

Security Implementation

Security is paramount in loyalty systems. Implement proper validation and authorization:

// Validate customer ownership before operations
public function validateCustomerOwnership(string $customerId, string $currentCustomerId): bool
{
    if ($customerId !== $currentCustomerId) {
        // Log unauthorized access attempt
        return false;
    }
    
    return true;
}

// Rate limiting for redemption operations
public function isRedemptionAllowed(string $customerId): bool
{
    $criteria = new Criteria();
    $criteria->addFilter(new EqualsFilter('customerId', $customerId));
    $criteria->addFilter(new EqualsFilter('reason', 'redemption'));
    $criteria->setLimit(10);
    
    // Check recent redemption attempts
    $recentRedemptions = $this->loyaltyPointRepository->search($criteria, Context::createDefaultContext());
    
    return $recentRedemptions->getTotal() < 5; // Max 5 redemptions per hour
}

Testing and Validation

Comprehensive testing ensures system reliability:

// Unit test example
public function testAddPoints(): void
{
    $customerId = 'test-customer-id';
    $points = 100;
    
    $this->loyaltyPointService->addPoints($customerId, $points, 'Test points');
    
    $customerPoints = $this->loyaltyPointService->getCustomerPoints($customerId);
    $this->assertEquals(100, $customerPoints);
}

public function testRedeemPoints(): void
{
    $customerId = 'test-customer-id';
    
    // Add initial points
    $this->loyaltyPointService->addPoints($customerId, 200, 'Initial points');
    
    // Redeem points
    $success = $this->loyaltyPointService->redeemPoints($customerId, 150);
    
    $this->assertTrue($success);
    $remainingPoints = $this->loyaltyPointService->getCustomerPoints($customerId);
    $this->assertEquals(50, $remainingPoints);
}

Conclusion

Shopware 6.7 provides a robust foundation for building custom loyalty systems through its modular architecture, entity system, and event-driven approach. This implementation demonstrates how to create a scalable, secure, and performant rewards point system that integrates seamlessly with existing Shopware functionality.

The system's extensibility allows for additional features like point expiration policies, tier-based rewards, and integration with external loyalty programs. By leveraging Shopware 6.7's modern development practices and performance optimizations, developers can create enterprise-grade loyalty solutions that enhance customer engagement and drive repeat purchases.

Remember to consider database optimization, caching strategies, and security measures when deploying such systems in production environments. The modular approach ensures maintainability and allows for easy feature additions as business requirements evolve.