2

In Laravel 4, query scopes are available on all queries (including ones generated by relations queries). This means that for the following (example) models:

Customer.php:

<?php
class Customer extends Eloquent {
    public function order() { return $this->hasMany('Order'); }
}

Order.php:

<?php
class Order extends Eloquent {
   public function scopeDelivered($query) { return $query->where('delivered', '=', true); }
   public function customer() { return $this->belongsTo('Customer'); }
}

Both of the following work:

var_dump(Order::delivered()->get()); // All delivered orders
var_dump(Customer::find(1)->orders()->delivered()->get()); // only orders by customer #1 that are delivered

This is useful from within a controller because the query logic for finding delivered orders doesn't have to be repeated.

Recently, though, I've been convinced that the Repository pattern is optimal for not only separation of concerns but also for the possibility of a ORM/DB switch or the necessity of adding middleware like a cache. Repositories feel very natural, because now instead of having scopes bloat my models, the associated queries are instead part of the Repository (which makes more sense because naturally this would be a method of the collection not the item).

For example,

<?php
class EloquentOrderRepository {
    protected $order;

    public function __construct(Order $order) { $this->order = $order; }
    public function find($id) { /* ... */ }
    /* etc... */
    public function allDelievered() { return $this->order->where('delivered', '=', true)->get(); }
}

However, now I have the delivered scope repeated, so to avoid violating DRY, I remove it from the model (which seems logical as per the justification above). But now, I can no longer can use scopes on relations (like $customer->orders()->delivered()). The only workaround here I see is somehow instantiating the Repository with the pre-made query (similar to what is passed to the scopes in the models) in the Relation base class. But this involves changing (and overriding) a lot of code and default behavior and seems to make things more coupled than they should be.

Given this dilemma, is this is misuse of a repository? If not, is my solution the only way to regain the functionality that I would like? Or is having the scopes in the models not tight enough coupling to justify this extra code? If the scopes aren't tight coupling, then is there a way to use both the Repository pattern and scopes while still being DRY?

Note: I am aware of some similar questions on similar topics but none of them address the issue presented here with queries generated by relationships, which do not rely on the Repository.

Community
  • 1
  • 1
Bailey Parker
  • 15,599
  • 5
  • 53
  • 91
  • I'm by no means a PHP architectural expert, but I think you are very close. Have you tried injecting the model in the constructor of the repository? Then `public function allDelievered() { return $this->order->where('delivered', '=', true)->get(); }` should work out for you. – user1669496 Jun 18 '14 at 15:29
  • @user3158900 You are correct. In my contrived example, I incorrectly implemented the Repository pattern. I've updated my post to reflect your correct amendment. Thanks! Unfortunately, this will still leave issues with the queries returned by relations not using my repositories. – Bailey Parker Jun 18 '14 at 15:35
  • Did some researching, this might be perfect for you. http://culttt.com/2014/03/17/eloquent-tricks-better-repositories/ – user1669496 Jun 18 '14 at 15:51
  • @user3158900 Interesting enough that article was the one that convinced me to try the Repository pattern. Unfortunately it too does not remedy the problem of Repository methods not being available on queries returned by Eloquent relations. To aid any future readers of this thread, I'll include some of the reading materials that I've found related to this subject. – Bailey Parker Jun 19 '14 at 09:52

1 Answers1

3

I've managed to find a solution. It's rather hacky and I'm not sure whether I consider it acceptable (it uses a lot of things in ways that they likely weren't meant to be used). To summarize, the solution allows you to move scopes to the repository. Each repository (on instantiation) is booted once, and during this process all of the scope methods are extracted and added to each query created by the eloquent model (via macros) by way of a Illuminate\Database\Eloquent\ScopeInterface.

The (Hack-y) solution

Repository Pattern Implementation

app/lib/PhpMyCoder/Repository/Repository.php:

<?php namespace PhpMyCoder\Repository;

interface Repository {

    public function all();

    public function find($id);
}

app/lib/PhpMyCoder/Repository/Order/OrderRepository.php:

<?php namespace PhpMyCoder\Repository\Order;

interface OrderRepository extends PhpMyCoder\Repository\Repository {}

Adding Eloquent Repositories (and a hack)

app/lib/PhpMyCoder/Repository/Order/EloquentOrderRepository.php:

<?php namespace PhpMyCoder\Repository\Order;

use PhpMyCoder\Repository\EloquentBaseRepository;

class EloquentOrderRepository extends EloquentBaseRepository implements OrderRepository {

    public function __construct(\Order $model) {

        parent::__construct($model);
    }

    public function finished() {

        return $this->model->finished()->get();
    }

    public function scopeFinished($query) {

        return $query->where('finished', '=', true);
    }
}

Notice how the repository contains the scope that would normally be stored in the Order model class. In the database (for this example), Order needs to have a boolean column finished. We'll cover the details of EloquentBaseRepository below.

app/lib/PhpMyCoder/Repository/EloquentBaseRepository.php:

<?php namespace PhpMyCoder\Repository;

use Illuminate\Database\Eloquent\Model;

abstract class EloquentBaseRepository implements Repository {

    protected $model;

    // Stores which repositories have already been booted
    protected static $booted = array();

    public function __construct(Model $model) {

        $this->model = $model;

        $this->bootIfNotBooted();
    }

    protected function bootIfNotBooted() {

        // Boot once per repository class, because we only need to
        // add the scopes to the model once
        if(!isset(static::$booted[get_class($this)])) {

            static::$booted[get_class($this)] = true;
            $this->boot();
        }
    }

    protected function boot() {

        $modelScope = new ModelScope();  // covered below
        $selfReflection = new \ReflectionObject($this);

        foreach (get_class_methods($this) as $method) {

            // Find all scope methods in the repository class
            if (preg_match('/^scope(.+)$/', $method, $matches)) {

                $scopeName = lcfirst($matches[1]);
                // Get a closure for the scope method
                $scopeMethod = $selfReflection->getMethod($method)->getClosure($this)->bindTo(null);

                $modelScope->addScope($scopeName, $scopeMethod);
            }
        }

        // Attach our special ModelScope to the Model class
        call_user_func([get_class($this->model), 'addGlobalScope'], $modelScope);
    }

    public function __call($method, $arguments) {

        // Handle calls to scopes on the repository similarly to
        // how they are handled on Eloquent models
        if(method_exists($this, 'scope' . ucfirst($method))) {

            return call_user_func_array([$this->model, $method], $arguments)->get();
        }
    }

    /* From PhpMyCoder\Repository\Order\OrderRepository (inherited from PhpMyCoder\Repository\Repository) */
    public function all() {

        return $this->model->all();
    }

    public function find($id) {

        return $this->model->find($id);
    }
}

Each time an instance of a repository class is instantiated for the first time, we boot the repository. This involves aggregating all "scope" methods on the repository into a ModelScope object and then applying that to the model. The ModelScope will apply our scopes to each query created by the model (as seen below).

app/lib/PhpMyCoder/Repository/ModelScope.php:

<?php namespace PhpMyCoder\Repository;

use Illuminate\Database\Eloquent\ScopeInterface;
use Illuminate\Database\Eloquent\Builder;

class ModelScope implements ScopeInterface {

    protected $scopes = array(); // scopes we need to apply to each query

    public function apply(Builder $builder) {

        foreach($this->scopes as $name => $scope) {

            // Add scope to the builder as a macro (hack-y)
            // this mimics the behavior and return value of Builder::callScope()
            $builder->macro($name, function() use($builder, $scope) {

                $arguments = func_get_args();

                array_unshift($arguments, $builder->getQuery());

                return call_user_func_array($scope, $arguments) ?: $builder->getQuery();
            });
        }
    }

    public function remove(Builder $builder) {

        // Removing is not really possible (no Builder::removeMacro),
        // so we'll just overwrite the method with one that throws a
        // BadMethodCallException

        foreach($this->scopes as $name => $scope) {

            $builder->macro($name, function() use($name) {

                $className = get_class($this);
                throw new \BadMethodCallException("Call to undefined method {$className}::{$name}()");
            });
        }
    }

    public function addScope($name, \Closure $scope) {

        $this->scopes[$name] = $scope;
    }
}

The ServiceProvider and Composer File

app/lib/PhpMyCoder/Repository/RepositoryServiceProvider.php:

<?php namespace PhpMyCoder\Repository;

use Illuminate\Support\ServiceProvider;
use PhpMyCoder\Repository\Order\EloquentOrderRepository;

class RepositoryServiceProvider extends ServiceProvider {

    public function register() {

        // Bind the repository interface to the eloquent repository class
        $this->app->bind('PhpMyCoder\Repository\Order\OrderRepository', function() {

            return new EloquentOrderRepository(new \Order);
        });
    }

}

Be sure to add this service provider to the providers array in the app.php config:

'PhpMyCoder\Repository\RepositoryServiceProvider',

And then add the app/lib to composer's autoload

"autoload": {
    "psr-0": {
        "PhpMyCoder\\": "app/lib" 
    },
    /* etc... */
},

This will require a composer.phar dump-autoload.

The Models

app/models/Customer.php:

<?php

class Customer extends Eloquent {

    public function orders() {

        return $this->hasMany('Order');
    }
}

Notice that for brevity, I've excluded writing a repository for Customer, but in a real application you should.

app/model/Order.php:

<?php

class Order extends Eloquent {

    public function customer() {

        return $this->belongsTo('Customer');
    }
}

Notice how the scope is not longer stored in the Order model. This makes more structural sense, because the collection level (repository) should be responsible for scopes applying to all orders while Order should only be concerned with details specific to one order. For this demo to work, order must have an integer foreign key customer_id to customers.id and a boolean flag finished.

Usage in the Controller

app/controllers/OrderController.php:

<?php

// IoC will handle passing our controller the proper instance
use PhpMyCoder\Repository\Order\OrderRepository;

class OrderController extends BaseController {

    protected $orderRepository;

    public function __construct(OrderRepository $orderRepository) {

        $this->orderRepository = $orderRepository;
    }

    public function test() {

        $allOrders = $this->orderRepository->all();

        // Our repository can handle scope calls similarly to how
        // Eloquent models handle them
        $finishedOrders = $this->orderRepository->finished();

        // If we had made one, we would instead use a customer repository
        // Notice though how the relation query also has order scopes
        $finishedOrdersForCustomer = Customer::find(1)->orders()->finished();
    }
}

Our repository not only contains the scopes for the child model, which is more SOLID. They also come with the ability to handle calls to the scope like a real Eloquent model would. And they add all scopes to each query created by the model so that you have access to them when retrieving related models.

Problems with this Approach

  • A lot of code for little functionality: arguably too much to accomplish the desired result
  • It's hacky: macros on Illuminate\Database\Eloquent\Builder and Illuminate\Database\Eloquent\ScopeInterface (in conjunction with Illuminate\Database\Eloquent\Model::addGlobalScope) are likely used in ways they weren't intended to be
  • It requires instantiation of the repository (MAJOR ISSUE): if you're within the CustomerController and you only have instantiated CustomerRepository, $this->customerRepository->find(1)->orders()->finished()->get() won't work as expected (the finished() macro/scope won't be added to each Order query unless you instantiate OrderRepository).

I'll investigate if there is a more elegant solution (which remedies the issues listed above), but this is the best solution I can find thus far.

Related Resources on the Repository Pattern

Bailey Parker
  • 15,599
  • 5
  • 53
  • 91