CakePHP: Highly codependent models, callback issues and data workflow

181 views Asked by At

I have a very cohesive relation between Order and Item models.

Order hasMany Item
Item belongsTo Order
Item hasMany ChildItem

ChildItem is alias for Item (it's a recursive model)

The Order Model has a special Order::prepare() function. It fires a Order.prepare event on all attached Behaviors, which control, validate and modify Item data, such as shipping, item/order weights, quantity, discounts, ie. validating and staging the data for a save operation. It also sets the Order.total, Order.weight, Order.status, ... fields.

Any behavior can also stop the preparation based on its constraints (stock limits, weight limits, vat, anything).

When Item is added to an existing Order:

  1. The Order and all its existing Items are retrieved from the database
  2. The new Item data is added to the Items array
  3. The Order is PREPARED (all behaviors run, Item.subtotals, Order.total, weights and such are recalculated ...)
  4. If prepare is successful, all the new (and modified) Order and Item data is saved with Order.saveAssociated.

After carefully considering multiple approaches I chose the third, but I got stuck:

beforeSave callbacks

This callback would ensure that these callbacks and calculations run on every item, but it makes it harder to retrieve related order data and items and run callbacks. It is also harder to know for sure if the data has already been prepared or not and recursion is also a problem. Harder to return prepared data instead of saving it.

Extending the Model::save methods

Recursion is also a problem, no way of returning the prepared data without saving and I generally avoid extending save().

Decoupling the preparation and save processes Creating a special workflow for order data manipulation through use of custom Model methods and callbacks (eg. Order::prepare and Order::commit). Behaviors run on non-standard events (beforePrepare, beforeCommit, etc..) and Model::save() stays untouched.

I would gladly provide additional details, but it is really a massive model and it would be a long question, I've summarized as much as possible. Any ideas or examples regarding the correct approach will be much appreciated.

2

There are 2 answers

2
Ayo Akinyemi On

I am not sure how complex your system is - but why dont you create an OrderPrepare class ( i typically make this class extend Object not AppModel or Order, and put it in the Model folder or I put it in the Lib folder), pass in the Order object and then you can perform whatever logic you want in that class?

0
Vanja D. On

I have managed to battle through this endeavor and I am posting the result and my current solution. I do not think it is completely conventional workflow, but it does the job quite well.

I have created BaseOrder and BaseItem classes, which extend AppModel and are meant to be extended with your own class.

I have created BaseOrder::fetch(), BaseOrder::stage() and BaseOrder::commit() methods, which are the core operations intended for work. All of them trigger before and after callbacks, which are custom event names, posted with a custom OrderEvent (extends CakeEvent) object.

BaseOrder::fetch()

This is used to get the current state of the Order from the database.

  • Collects all association requirements from it's related BaseItem classes and Behaviors. This allows each behavior to make custom associations on the fly (e.g. Couponed behavior attaches Order hasMany Coupon), modify query=>contain data, to ensure Order::fetch() retrieves the proper data for the workflow.
  • Runs the generated query
  • Runs the AfterFetch callback, which allows any Behavior or OrderItem instances to attach or adjust additional data needed.

BaseOrder::stage()

Recieves any kind of Order-related data as an argument, does a Order::fetch(), applies and modifies the data and runs all the behaviors' and OrderItem's beforeStage, stage and afterStage callbacks.

This method is expected to return a real state of the order, with all details resolved, such as product data, financial details, discounts, Behavior-attached data, etc.

BaseOrder::commit()

Takes a staged order array, validates financials, commits the data to the database.

Additionally, the BaseItem and BaseOrder both have an afterSave callback, which triggers a recalculation of the related Order record's financials, in case any data is saved manually to the database.

This way, I can easily stage/commit an Order for all the bells and whistles, or I can update the order/item data directly and still have the financial data integrity.

I am thinking of moving the calculation side of things to a MySQL stored procedure, which would run automatically after each save, but it takes the fine-tune control out of the app (like round-off errors, default tax rates, etc.)

I am expecting someone to go no, no, no, you're doing it wrong and enlighten me with a better idea since all of this feels quite dirty.

The biggest "issue" I have is getting all the related BaseItem-extending Models and traversing the data array to "fish" them out for calculation. This allows me to have different models (like ProductItem, ShippingItem) and different relations between them, but presents a slight overhead.