views:

860

answers:

3
+1  A: 

hi,

well i'd point out these two things:

  1. you have a whole lot of hard-coded configuration stuff ... use cake's Configure to do that ... like the $cost variable in the first controller, or the $paypalData ... you can get it from elsewhere if you want (for example the flash should come from language files), but don't mix configuration with implementation ... this will make the classes much more readable, and maintenance a lot easier ...
  2. encapsulate all the socket stuff into a new helper class ... you may need it elswhere ... and really, it obfuscates what happens ... also, consider moving out other parts of that boa controller of yours ... for example just tuck some other class under it, that does the implementation ... you should always try to have small and concise front controllers, because it makes much easier to understand what happens ... if anyone cares for implementation details, he can look into the corresponding class ...

that's what i think is cakeish ...

back2dos
I plan to move everything to Configure eventually, it was just hardcoded while I test to make sure everything is working perperly. I probably should start to move everything to language files although I haven't had a chance to play with them yet. While we're on it, what's the differece between l10n and i18n?
DanCake
10 and 18 represent the amount of characters between the first and last letter. They stand for L-ocalizatio-n and I-nternationalizatio-n respectively. The former being the act of actually producing a translation for a particular language and the latter being the act of enabling an application to be translated (or localised) later (ie. by using the __() function in Cake).
deizel
Great! Thanks for the help
DanCake
+17  A: 

Lesson 1: Don't use PHP's superglobals

  • $_POST = $this->params['form'];
  • $_GET = $this->params['url'];
  • $_GLOBALS = Configure::write('App.category.variable', 'value');
  • $_SESSION (view) = $session->read(); (helper)
  • $_SESSION (controller) = $this->Session->read(); (component)
  • $_SESSION['Auth']['User'] = $this->Auth->user();

Replacements for $_POST:

<?php
    ...
    //foreach ($_POST as $key => $value) {
    foreach ($this->params['form'] as $key => $value) {
    ...
    //if (isset($_POST['test_ipn'])) {
    if (isset($this->params['form']['test_ipn'])) {
    ...
?>

Lesson 2: Views are for sharing (with the user)

Code documented "Compiles premium info and send the user to Paypal" doesn't send the user to PayPal. Are you redirecting in the view?

<?php
    function redirect($premiumId) {
        ...
        $this->redirect($url . '?' . http_build_query($paypalData), 303);
    }

Redirect at the end of your controller and delete the view. :)

Lesson 3: Data manipulation belongs in model layer

<?php
class PremiumSite extends AppModel {
    ...
    function beforeSave() {
        if ($this->data['PremiumSite']['type'] == "1") {
            $cost = Configure::read('App.costs.premium');
            $numberOfWeeks = ((int) $this->data['PremiumSite']['length']) + 1;
            $timestring = String::insert('+:number weeks', array(
                'number' => $numberOfWeeks,
            ));
            $expiration = date('Y-m-d H:i:s', strtotime($timestring));
            $this->data['PremiumSite']['upfront_weeks'] = $weeks;
            $this->data['PremiumSite']['upfront_expiration'] = $expiration;
            $this->data['PremiumSite']['cost'] = $cost * $numberOfWeeks;
        } else {
            $this->data['PremiumSite']['cost'] = $cost;
        }
        return true;
    }
    ...
}
?>

Lesson 4: Models aren't just for database access

Move code documented "Enables premium site after payment" to PremiumSite model, and call it after payment:

<?php
class PremiumSite extends AppModel {
    ...
    function enable($id) {
        $transaction = $this->find('first', array(
            'conditions' => array('PaypalNotification.id' => $id),
            'recursive' => 0,
        ));
        $transactionType = $transaction['PaypalNotification']['txn_type'];

        if ($transactionType == 'subscr_signup' ||
            $transaction['PaypalNotification']['payment_status'] == 'Completed') {
            //New subscription or payment
            ...
        } elseif ($transactionType == 'subscr-cancel' ||
            $transactionType == 'subscr-eot') {
            //Subscription cancellation or other problem
            ...
        }
        return $this->saveAll($data);
    }
    ...
}
?>

You would call from controller using $this->PaypalNotification->PremiumSite->enable(...); but we aren't going to do that, so let's mix it all together...

Lesson 5: Datasources are cool

Abstract your PayPal IPN interactions into a datasource which is used by a model.

Configuration goes in app/config/database.php

<?php
class DATABASE_CONFIG {
    ...
    var $paypal = array(
        'datasource' => 'paypal_ipn',
        'sandbox' => true,
        'api_key' => 'w0u1dnty0ul1k3t0kn0w',
    }
    ...
}
?>

Datasource deals with web service requests (app/models/datasources/paypal_ipn_source.php)

<?php
class PaypalIpnSource extends DataSource {
    ...
    var $endpoint = 'http://www.paypal.com/';
    var $Http = null;
    var $_baseConfig = array(
        'sandbox' => true,
        'api_key' => null,
    );

    function _construct() {
        if (!$this->config['api_key']) {
            trigger_error('No API key specified');
        }
        if ($this->config['sandbox']) {
            $this->endpoint = 'http://www.sandbox.paypal.com/';
        }
        $this->Http = App::import('Core', 'HttpSocket'); // use HttpSocket utility lib
    }

    function validate($data) {
       ...
       $reponse = $this->Http->post($this->endpoint, $data);
       ..
       return $valid; // boolean
    }
    ...
}
?>

Let the model do the work (app/models/paypal_notification.php)

Notifications are only saved if they are valid, sites are only enabled if the notification is saved

<?php
class PaypalNotification extends AppModel {
    ...
    function beforeSave() {
        $valid = $this->validate($this->data);
        if (!$valid) {
            return false;
        }
        //Minor change to use item_id as premium_site_id
        $this->data['PaypalNotification']['premium_site_id'] = 
            $this->data['PaypalNotification']['item_number'];
        /*
        $this->data['PaypalNotification'] = am($this->data, // use shorthand functions
            array('premium_site_id' => $this->data['item_number']));
        */
        return true;
    }
    ...
    function afterSave() {
        return $this->PremiumSite->enable($this->id);
    }
    ...
    function validate($data) {
        $paypal = ConnectionManager::getDataSource('paypal');
        return $paypal->validate($data);
    }
    ...
?>

Controllers are dumb. (app/controllers/paypal_notifications_controller.php)

"Are you a post? No? .. then I don't even exist." Now this action just shouts, "I save posted PayPal notifications!"

<?php
class PaypalNotificationsController extends AppModel {
    ...
    var $components = array('RequestHandler', ...);
    ...
    function callback() {
        if (!$this->RequestHandler->isPost()) { // use RequestHandler component
            $this->cakeError('error404');
        }
        $processed = $this->PaypalNotification->save($notification);
        if (!$processed) {
            $this->cakeError('paypal_error');
        }
    }
    ...
}
?>

Bonus Round: Use provided libraries instead of native PHP

Refer to previous lessons for examples of the following:

  • String instead of sprintf
  • HttpSocket instead of fsock functions
  • RequestHandler instead of manual checks
  • am instead of array_merge

These can prevent coding errors, reduce amount of code and/or increase readability.

deizel
First of all, thank you for that excellent post, you have given me a lot to think about.Lession 1: The process() section wasn't written by me, I just made a few changes. I thought it was strange that the person used $_POST rather than $this->data, I will probably rewrite it.Lession 2: Yes, it does redirect the user in the view although not a standard redirect. Take a look at my original post.Lesson 3: I agree, Plus your code is much more elegant then how I would've done it.Lession 4: I will start to use models more, it definately slims down the controller and reads easier.
DanCake
Hit the character limit and the loss of formatting makes it a little hard to read. Lession 5: PayPal IPN really a regular api, the data is sent along with the user which may make using a DataSource difficult to implement. I know the code in the view is quite bad, I couldn't find any other way to accomplish this.Bonus: I really must properly look though the CakePHP api, I've experimented with the HttpSocket component but not the RequestHandler.Thanks again for all your help
DanCake
Thank you for providing good example code, I actually enjoyed thinking about how one might refactoring it. :) In response to your points: 1. I have updated this to $this->params['form'] as $this->data only applies to data submitted by forms created with Cake's FormHelper; 2. I would test if GET works here since the amount of data isn't excessive. If PayPal only accepts POST then I can only suggest you add a submit button for those without JavaScript enabled; 3/4. This has been coined as the "Fat model, skinny controller" methodology by someone, in case you need to put it into a few words;
deizel
5. If the datasource route isn't for you, a PaypalComponent with a validate method will allow validation from the controller before calling save. You can also add a getParams method or similar that returns the array of data you need to submit via that hidden form (if GET isn't an option).
deizel
From what I have seen, the initial interaction with PayPal is POST but you can choose how it calls back to process(). A Datasource and a Component seem to exist in the bakery although both use a different api but should be good for reference.
DanCake
+5  A: 

Except for all the stuff noted by deizel (great post btw), remember one of the basic cake principles: fat models, skinny controllers. You can check this example, but the basic idea is to put all your data-mangling stuff in your models. Your controller should (mostly) be just a link between your models and views. Your PremiumSitesController::index() is a perfect example of something that should be somewhere in your model (as pointed out by deizel).

Chris Hartjes has also written a book about refactoring, you might want to take a look at it if you really want to learn (it's not free, but it's cheap though). Also, Matt Curry has one, with a cool name: Super Awesome Advanced CakePHP Tips, and it's completely free for download. Both make for a good read.

I'd also like to plug my own article about cake which I like to believe is important for code quality in cake: Code formatting and readability. Though I understand if people disagree.. :-)

dr Hannibal Lecter
You're the person who created NeutrinoCMS? I found that near the beginning and looking through the code has been a great help. Also, I will definitely read those over the next few days.
DanCake
Yes, that would be me :) I'm glad it helped you learn, it sure has helped me. Though lately, spare time has been scarce..
dr Hannibal Lecter