RSS
 

Archive for the ‘CakePHP’ Category

Dynamic database switching

25 Feb

Some might remember my old post about development vs. productive setup. It was mainly about how to switch dynamically based on the current environment. This can be useful if you want to have a single DB config file which will be distributed/uploaded to all environments. In my case: synced via shell script.

Since then I rewrote it quite often and it is now published in my Setup plugin (in /Lib). It now has some more useful goodies. The config file can be kept more DRY (Don’t Repeat Yourself) and does allow automatic test config.

The most important new feature is the automatic environment awareness in CLI mode (using path).

Basic usage

Install the plugin in your Plugin folder first.

Your database.php then uses the class this way:

App::uses('BaseConfig', 'Setup.Lib');
 
class DATABASE_CONFIG extends BaseConfig {
 
    public $default = array(
        'environment' => array('localhost', 'domain'), // define the environments - optional
        'path' => array('/some/path/to/app/'), // define the paths - optional
        'datasource' => 'Database/Mysql',
        'persistent' => false,
        'host' => 'localhost',
        'login' => 'root',
        'password' => '',
        'database' => 'table_name',
        'prefix' => 'app_', //optional (I like to use prefixes, though)
        'encoding' => 'utf8'
    );
Actually, using the BaseConfig class you can cut it down to:
public $default = array(
    'environment' => array('localhost', 'domain'),
    'path' => array('/some/path/to/app/'),
    'datasource' => 'Database/Mysql',
    'host' => 'localhost',
    'login' => 'root',
    'password' => '',
    'database' => 'my_app',
    'prefix' => 'app_'
);
encoding defaults to utf8 and persistent defaults to false.

Both environment and path are used to determine the correct config. The first dynamically selects the correct config based on the $_SERVER['HTTP_HOST'] setting and therefore the domain you are running the application with. The latter does the same on CLI (Command Line Interface) – using the cake console. There we have to domain and therefore need the paths to find the correct configuration. If you use the shell only locally then you can skip setting the paths, of course.

Test config

That is probably the most useful goodie. You don’t have to take care of this anymore. It will also ensure that your “live” tables will not get touched, modified, overwritten etc.

If you only defined the default config yet, the BaseConfig class will automatically set the test config to the same database, but with a different prefix (defaults to zzz_). You can overwrite the defaults very easy, though:

public $test = array(
    'database' => 'my_app_test',
    'merge' => true,
    'prefix' => 'app_',
);
In this case we force the merge with our default config and define an own database table as well as the same prefix to completely replicate the default settins on a secondary test evironment. All other config values will be automatically merged.

Manuel fixation

You can also manually enforce a certain DB config in your bootstrap or config.php file:

Configure::write('Environment.name', 'stage');
//or
$config['Environment'] = array(
    'name' => 'stage'
);
This will make the class use the $stage config (needs to be available then, of course).

Example

So for 3 environments, lets call them development, stage and live, we can easily define them like so:

public $default = array(
    'environment' => array('localhost', 'local.domain'),
    'path' => array('/some/path/to/app/'),
    'datasource' => 'Database/Mysql',
    'host' => 'localhost',
    'login' => 'root',
    'password' => '',
    'database' => 'my_app',
);
 
public $stage = array(
    'environment' => array('test.domain.com'),
    'path' => array('/some/stage/path/to/app/'),
    'datasource' => 'Database/Mysql',
    'host' => 'localhost',
    'login' => 'root',
    'password' => '123',
    'database' => 'my_app_stage',
);
 
public $live = array(
    'environment' => array('www.domain.com', 'domain.com'),
    'path' => array('/some/live/path/to/app/'),
    'datasource' => 'Database/Mysql',
    'host' => 'localhost',
    'login' => 'root',
    'password' => '456',
    'database' => 'my_app_live',
);
Note: In all cases the test config will adjust itself to the current environment as we didn’t specify anything for it.

Final notes – some pitfalls and tips

Make sure you put the exact paths to your application in path – including the trailing slash! If you want to confirm that everything works in CLI, as well, use my CurrentConfig shell to output the current configuration for the console:

..\lib\Cake\Console\cake Setup.CurrentConfig
It should then display the default and test DB config as well as the current Cache config.

 
No Comments

Posted in CakePHP

 

Spellchecking with (Cake)PHP

24 Feb

I wrote some cake1.3 libs years ago which would request google’s spellchecker API. This is great for small and unregular lookups. But as soon as you need to use it more excessive an internal server solution is not only much faster but also capable of high frequent lookups. You can – for instance – check a complete book with hundred thousands of words in seconds.

The library pspell seems to be deprecated in PHP5.3. The way to go is enchant.

Enchant PHP Extension

On windows you don’t have to do much. WAMP with PHP5.3 comes with the Enchant extension right away. You only need to activate it (php_enchant) by menu or by manually removing the # char for this extension in php.ini. Don’t forget to restart your Apache.

For linux you might first want to run apt-get install php5-enchant to install the basic library. You will then need to add the extension to php.ini. If someone has any details on this, please let me know and I update this tut.

Once it is running you should see an “enchant” module entry on your phpinfo page. On my system it seems to be Version 1.1.0.

SpellLib for CakePHP2

The lib is in my Tools plugin and is called SpellLib (in /Lib/).

We need to provide the lib with dictionaries. Those can be found online at different locations. One is here. You can probably use all kinds of dictionaries which end with the extension .dic. Now store them in you global vendors folder: /vendors/dictionaries/[engine]/ whereas [engine] is your preferred engine (defaults to myspell). If you want to store it in a different path, see the last chapter on possible options.

Once they are in your vendors directory you can check on them:

$this->SpellLib = new SpellLib();
$dicts = $this->SpellLib->listDictionaries();
This should display a list with at least one tag like en_GB or de_DE based on what you downloaded.

Basic usage (with english spell checking):

$this->SpellLib = new SpellLib();
if ($this->SpellLib->check($word)) {
    //everything is fine
} else {
    //contains an array of words that could be the correct ones
    $suggestions = $this->SpellLib->suggestions($word);
}
See the test case for details.

Options

If you want to store those dictionary files in another vendor path, you can configure this by using Configure class or simply by passing the path on to the class:

// in your configs
$config['Spell'] = array(
    'path' => CakePlugin::path('Tools') . 'Vendor' . DS . 'dictionaries' . DS,
    'engine' => ENCHANT_ISPELL,
    'lang' => 'de_DE'
);
 
// passing it on as `path` param:
$this->SpellLib = new SpellLib(array('path'=>CakePlugin::path('Tools') . 'Vendor' . DS . 'dictionaries' . DS));

To use other languages dynamically, German for example, use the lang param:

$this->SpellLib = new SpellLib(array('lang'=>'de_DE'));

Any feedback is appreciated!

 
No Comments

Posted in CakePHP, PHP

 

What REALLY speeds up your cakephp app

13 Feb

There are already many (partly outdated) blog entries (e.g. 8-ways-to-speed-up-cakephp-apps) and stackoverflow questions/answers (e.g. speeding-up-cakephp) regarding this question. But from years of experience I want to outline the more important ones pretty quick.

I will not talk about the little things or the ones that will only affect 5% of the website. And no – debug level 0 is not a speed improvement. It is an absolute requirement for any live application and can therefore be seen as default setting.

One last note: I want to address dynamic websites here that cannot make use of extensive view caching or even html caching as described in the first link – at least for most of the pages. They undoubtedly are the fasted way to serve content but can usually only applied to very few views. The tips below are valid for all requests across your application.

1. Opcode Cache

Opcode Cache will keep your php files in the memory. It will usually also store the compiled bytecode of it to further improve execution time. But it is not only improving speed a great bit, it is also reducing the memory used for a single request by more than 50% allowing your server to serve twice as much requests (and therefore users) before overworking itself. A mediumsized cake2 application of mine uses 13.9 MB memory. With Opcode Cache it is reduced to 7.7 MB. And the speed is pretty much accordingly.

Installation is almost too easy. Example for ubuntu and apache: apt-get install php-apc and restarting the apache should do the trick. But there are other modules, as well (Xcache, Memcached, …)

Gain: in most cases more than 100% (more than twice as fast)

2. Also use memory caching for all temporary data

This mainly includes “tmp files” via CacheEngine which can be cake core tmp files or app tmp files. All your cached elements will be included faster this way, as well. If you cache your sql queries they will also be affected positively compared to a simple file cache (although a file cache itself is better than nothing).

Cake2.x automatically tries to use the fastest CacheEngine if available (and defined in your core.php):

$engine = 'File';
if (extension_loaded('apc') && function_exists('apc_dec') && (php_sapi_name() !== 'cli' || ini_get('apc.enable_cli'))) {
    $engine = 'Apc';
}

Gain: 10-40%

3. Speed up (Reverse) Routing

This will help for sites with many dynamic links. Without caching the generated urls Router class creates them over and over again. You can use my UrlCache plugin which basically does exactly that. I used the ideas of lorenzo and mcurry and applied some fixes and improvements. It now stores commonly used links (without any params) in a global cache file while using single cache files per site for the specific urls (e.g. from pagination/filtering).

If you only got a bunch of those links on a single page, this is wasted time, though. But for most projects it is a real time saver – from 0.3 seconds up to 1 second less request time.

Important note: You need to manually configure your _cake_core_ cache for this:

Cache::config('_cake_core_', array(
    'engine' => $engine,
    'prefix' => 'cake_core_',
    'path' => CACHE . 'persistent' . DS,
    'serialize' => ($engine === 'File'),
    'duration' => $duration,
    'lock' => true, # !!!
));
Note the exclamation marks. If you do not apply 'lock' => true you will end up with quite a few broken cache files. This will reset the whole thing all the time due to concurrent requests/writing and you gain nothing… See this ticket for details. With this setting the problems seems not to occur.

To further optimize performance I invented a dual cache system with reading unlocked and writing locked. See the plugin for details on how to use it for best results.

Gain: 10-50% (the more links on a page the more gain)

4. Optimize data, compress and cache assets

If you got 50 icons on your site, you might want to create a single sprite instead of creating +50 requests to your server here. You also should combine your js/jss and apply some compressing.

In general it is a good idea to send all data gzip compressed – using apache’s mod_deflate module for example. This way only half the amount of bytes need to be sent to the user. Especially with (slow) mobile connections this will increase speed up to 100%.

Using mod_headers you can make sure asset files are not permanently requested if they didn’t change. The website will feel faster for the user and the server has to serve less requests.

Files of the same type (css, js) should be packed and sent combined to a single file (as opposed to maybe 20-30 single files). Undoubtedly Mark Story’s AssetCompress plugin is one of the best ones out there to address exactly these issues.

Gain: 10-30% (might also depend on the browser)

Some stuff I didn’t try yet

In Cake2 you can even store sessions in the Cache (and therefore probably in the memory). This should also speed up the application remarkably compared to database sessions for example.

And the list of obligatory tips which should already be well known at last

  • Use containable to restrict the amount of data to be fetched as the database connection usually is the bottle neck of any app
  • Try to load stuff dynamically and only if needed (With Cake2 and lazy loading everywhere via App::uses() this shouldn’t be difficult to do anymore)
  • Try to decouple time consuming stuff from the frontend. So rendering large images, creating complicated pdfs, sending bulk emails can all be passed on to some “Queue” which asynchronously works them off piece by piece without slowing down the response time for the action.
  • As mentioned above you can cache complete pages to the View cache preventing the complete dispatch process to run. Such a pre-rendered html file will be served right away. This is useful for pages with mainly static content.
  • Avoiding $uses in the controller (except for its own model, of course) – meaning we should never have more than the primary model attached to it.
  • Make sure you send the right headers for proper client-side caching. Some browsers might not respect it, but the ones that do will profit from the increase in speed.
  • Don’t use requestAction

Update – 2012-02-20 – Benchmark

There is a benchmark site comparing all kinds of frameworks.. Unfortunately, using 1.3 instead of 2.x. But either way it displays how important it is to apply above optimization. If that is the case I bet the results will be way less dramatic.

 
8 Comments

Posted in CakePHP, PHP

 

Qlogin – Quicklogins für CakePHP

08 Feb

Have you ever thought how nice it would be to send emails with an url that automatically logs you in? Especially for a messaging system this can be quite handy: One click in the notification email and you can answer right away.

How does it work?

Here an example with a notification email:

$text = 'You got mail!'.PHP_EOL;
if (!isset($this->Qlogin)) {
    $this->Qlogin = ClassRegistry::init('Tools.Qlogin');
}
$text .= $this->Qlogin->url(array('admin'=>false, 'plugin'=>false, 'controller'=>'conversations', 'action'=>'view', $conversation['Conversation']['id'])), $user['User']['id']).PHP_EOL;
//send email
We create a quicklink using the url where the user should be redirect to after the login as first param and the actual user_id to authenticate with as second param. Pretty straight forward. Let’s say, the generated url is http://domain/qlogin/1234567890 .

Once the user clicks on the link, the “go” action of the QloginController gets triggered and tries to find the corresponding user as well as target url. If found, the user is logged in and directly passed on to the desired page. Note: If the user is already logged in, he will be redirected immediately (skipping the login).

I added this to my routes.php

Router::connect('/qlogin/*', array('plugin'=>'tools', 'controller' => 'qlogin', 'action'=>'go'));
in order to result in the above url which is pretty short and convenient.

The code

Its available at github (currently only for 2.x): Tools plugin. You need the model as well as the controller.

Some notes

The Module is still pretty basic. But it works flawlessly with my setup. I would like your feedback on it, though. AuthComponent::login() should respect the scope you defined as well as the default redirect urls. Right now I am trying out the Qlogins in combination with AutoLogin – if those cookies don’t intefere. But it seems to work fine.

Dependencies

Model CodeKey (@see article) (to store the tokens) as well as the url validation method and get() of my MyModel (can also be put into your AppModel). And since it is deeply integrated in my usual everyday apps it also requires at least the CommonComponent of the Tools plugin. For the optional admin backend there are more dependencies. So basically you need:

//AppController
public $components = array('Tools.Common');
 
//AppModel
App::uses('MyModel', 'Tools.Lib');
class AppModel extends MyModel {}
Of course you may through out any methods you don’t need.

 
No Comments

Posted in CakePHP

 

New year, new cake version – 2.1

09 Jan

The new version 2.1 is almost fully backwards compatible.

The migration guide from 2.0 to 2.1 can be found at book.cakephp.org/2.0/en/appendices/2-1-migration-guide.html

New stuff for the upgrade shell

I used this chance to enhance my version of the upgrade shell and added – besides some minor fixes – a command cake21. It should take care of all api standard updates for 2.1: git commit1 and 2 The Upgrade Plugin can be found here.

It currently updates Auth::allow() and layout stuff ($content_for_layout, $scripts_for_layout etc).

Feel free to add missing replacements.

 
No Comments

Posted in CakePHP

 

Tools Plugin – Part 2: Contact Form

15 Dec

I want to show how easy it is to make a solid and universal contact form in Cake(2). The full code can be found in the github rep of the Tools plugin.

Model

The most important part first: We need a solid validation for the form. How many forms are out there that do not have any validation. The first thing I always do on about any public contact form: Hit the submit button and smile it if tells me that my empty form “has just been successfully sent”. We can do better than that :)

class ContactForm extends ToolsAppModel {
 
    protected $_schema = array(
        'name' => array('type' => 'string' , 'null' => false, 'default' => '', 'length' => '30'),
        'email' => array('type' => 'string' , 'null' => false, 'default' => '', 'length' => '60'),
        'subject' => array('type' => 'string' , 'null' => false, 'default' => '', 'length' => '60'),
        'message' => array('type' => 'text' , 'null' => false, 'default' => ''),
    );
 
    public $useTable = false;
 
    public $validate = array(
        'name' => array(
            'notEmpty' => array(
                'rule' => array('notEmpty'),
                'message' => 'valErrMandatoryField',
                'last' => true
            )
        ),
        ...
    );
The _schema var mocks a database table so that we don’t really need one. This helps the FormHelper to generate the inputs (maxlength, type, …). The validation rules will make sure the email is valid and the user actually entered some text.

Controller

The logic cakes care of the validation on POST. /Controller/ContactController.php

<?php
class ContactController extends AppController {
 
    public $uses = array('Tools.ContactForm');
 
    public function index() {
        if ($this->request->is('post') || $this->request->is('put')) {
            if (!$this->Session->check('Auth.User.id')) {
                $this->ContactForm->Behaviors->attach('Tools.Captcha');
            }
            $this->ContactForm->set($this->request->data);
            if ($this->ContactForm->validates()) {
                $name = $this->request->data['ContactForm']['name'];
                $email = $this->request->data['ContactForm']['email'];
                $message = $this->request->data['ContactForm']['message'];
 
                //send email with CakeEmail
            } else {
                $this->Common->flashMessage(__('formContainsErrors'), 'error');
            }
 
        }
        $this->helpers = array_merge($this->helpers, array('Tools.Captcha'));
    }
}
As you can see it only sends emails after successfully validating. For public contact forms I usually like some easy captcha behavior attached so that spam doesnt reach me. You can omit that, of course. Also note: you should use your own setFlash() method instead of mine!

Last but not least: View

/View/Contact/index.ctp

<?php echo $this->Form->create('ContactForm');?>
    <fieldset>
        <legend><?php echo __('contactLegend');?></legend>
    <?php
        echo $this->Form->input('name');
        echo $this->Form->input('email');
 
        echo $this->Form->input('subject');
        echo $this->Form->input('message', array('rows'=>15));
 
        if (!$this->Session->read('Auth.User.id')) {
            echo $this->Captcha->input('ContactForm');
        }
    ?>
    </fieldset>
<?php echo $this->Form->submit(__('Submit')); ?>
<?php echo $this->Form->end();?>

Result

Browse to /contact/ That should display the form right away. After the successful POST you should redirect back to the contact form (emptied then) or to another custom page.

Final notes

Currently, the Model is in the Tools.Plugin. You could put it into your normal app model folder, as well. But I use it in many projects and therefore I want to keep it dry. Feel free to adjust any of the code to your own needs. Same goes for my own custom methods like $this->Common->flashMessage() etc.

Some might wonder why “ContactController” and not “ContactsController”. The latter would be cake conventions. But there are situations where you can and should diverge form those. As in this case where “contacts” would mean more like sth to manage your addressbook. It also makes the url more meaningful out of the box (without any custom routes). With cake2 this all works without any additional customization.

The other thing: I didn’t name the Model “Contact” but “ContactForm” in order to not create possible conflicts which exactly such contacts/contact mangement MVCs (as I did in a contact management suite).

 
No Comments

Posted in CakePHP

 

Unit-Testing Tips for 2.0 and PHPUnit

04 Dec

Quite some time ago I wrote about Unit testing. But that was still in 1.3 and with SimpleTest. A lot has changed since then.

Execution Order

The documentation wasn’t all that clear about it. So I tried it with the following test file:

App::uses('MyCakeTestCase', 'Tools.Lib');
 
class TestCaseExecutionOrderTest extends MyCakeTestCase {
 
    public function setUp() {
        parent::setUp();
 
        $this->out('setUp');
    }
 
    public function tearDown() {
        $this->out('tearDown'); 
 
        parent::tearDown();
    }
 
    public function startTest() {
        $this->out('startTest');
    }
 
    public function endTest() {
        $this->out('endTest'); 
    }
 
 
    public function testFoo() {
        $this->out('* foo *');
    }
 
    public function testBar() {
        $this->out('* bar *'); 
    }
 
}
Note the parent calls for setUp and tearDown. Those need to be set. The other 2 methods don’t require this.

The result was pretty obvious:

setUp
startTest
* foo *
endTest
tearDown
 
setUp
startTest
* bar *
endTest
tearDown

Debug Output with PHPUnit >= 3.6

On the release notes of CakePHP2.0.3 you could find the note

A big difference people will notice when writing unit tests is that all output is swallowed by PHPUnit and not presented in either the web tester page nor in the CLI tester. To overcome this annoyance use the--debug modifier if you are using the CLI interface
Well, that broke a lot of existing debug code, of course. So I tried to come up with a solution. The Shell uses a specific output(). Why not use sth like that in test cases, too?

/**
 * outputs debug information during a web tester (browser) test case
 * since PHPUnit>=3.6 swallowes all output by default 
 * this is a convenience output handler since debug() or pr() have no effect
 * @param mixed $data
 * @param bool $pre should a pre tag be enclosed around the output
 * @return void
 * 2011-12-04 ms
 */
public function out($data, $pre = true) {
    if ($pre) {
        $data = pre($data);
    }
    echo $data;
    if (empty($_SERVER['HTTP_HOST'])) {
        # cli mode / shell access: use the --debug modifier if you are using the CLI interface
     return;
    }
    ob_flush();
}
As you probably noticed, I use it in the above “execution tryout”. You can make yourself a custom “MyCakeTestCase” class with then extends the core test case class and put it in there.

UPDATE January 2012

The core (>=2.0) now contains a native way to enable debugging output for your tests. You need to append &debug=1 to the url. Not ideal, but it works.

 
 

Serving views as files in Cake2

21 Nov

Actually, its not that different in Cake1.3. But as I just played around with it in 2.0, I will stick to that version for examples.

How to start

Skip this, if you want to cut to the chase.

In your routes.php you need to add Router::parseExtensions(); (or only specific ones). That tells cake that urls ending with “.xyz” will be served as files (either inline or as download attachment).

Setup

Don’t forget to add the RequestHandler to your Controller components list:

public $components = array('RequestHandler');
This will be an important part in the following auto-magic.

Let’s say you want to display an invoice as pdf. The normal url is /invoices/view/1. Now, we set a link in the view to the file like so:

$this->Html->link('View as PDF', array('action'=>'view', 'ext'=>'pdf', 1));

Since we use the RequestHandler Component cake automatically detects that this will be a pdf file, it will a) set the correct header (application/pdf) b) will try to find the specific pdf view in /View/Invoices/pdf/index.ctp and the pdf layout in /View/Layouts/pdf/default.ctp

That’ all :)

Download it right away

Files like pdf can be displayed inline. So the browser will usually not force you to download it. If you want this, though, you need to call $this->request->download($filename); in your controller action or in your layout.

Note: If your browser does not understand the file format (in this case pdf) it will probably trigger the download right away.

Browser bugs and easter eggs

Well, you could also call it a bug. But during my tryouts I found out that files served inline (Content-Disposition: inline; filename=”…”) will not use the given filename on save. They will be saved with the name in the url instead. In the above example it would be “1.pdf”. I did some research: Thats a well known browser deficiency that nobody yet fixed. Or so it seems. Ok, but nobody wants his invoice to be “1.pdf”. So what can we do about it?

I found a pretty well working workaround:

$this->Html->link('View as PDF', array('action'=>'view', 'ext'=>'pdf', 1, 'invoice-2011-11-01_some_customer_tag'));
As you can see we simply add our filename to the url – after the id (!).

Since it is only “filename cosmetics” we dont need to add this second passed param to our method:

public function view($id = null) {}
It will be ignored in the action itself.

So the generated url is /invoices/view/1/invoice-2011-11-01_some_customer_tag.pdf and will result in a file saved as “invoice-2011-11-01_some_customer_tag.pdf”. Job done.

Let me know what you think.

Example for PDFs

A pretty quick example how to output your content as pdf using DOMPDF.

In your layout (default.ctp in /pdf/):

App::import('Vendor', 'dompdf/dompdf.php');
$dompdf = new DOMPDF();
$dompdf->load_html(utf8_decode($content_for_layout), Configure::read('App.encoding'));
$dompdf->render();
echo $dompdf->output();
For me it looked like that without utf8_decode the DOMPDF lib seems to be buggy – although it claims to support utf8.

Example for ics (ical calendar) files

Just a very basic example using an IcalHelper from my Tools Plugin.

Please note: There is a pending ticket on this. Until then you either need to manually patch the ResponseClass (quick solution) by adding the missing mimetype 'ics' => 'text/calendar', or define it in your Controller via $this->response->type(array('ics' => 'text/calendar')); and set all paths manually… Hopefully the second issue for unknown mimetypes will be addressed soon, as well.

Controller code:

$reservation = ...;
$this->plugin = 'Tools'; //not necessary, only that I want to store the layout once (in the plugin)
$this->set(compact('reservation'));
$this->helpers[] = 'Tools.Ical';

Layout (in /Plugin/Tools/View/Layouts/ics/default.ctp):

<?php echo $content_for_layout; ?>

View (in /View/Reservations/ics/export.ctp):

$data = array(
    'start' => $this->Time->toAtom($reservation['Reservation']['time']),
    'end' => $this->Time->toAtom(strtotime($reservation['Reservation']['time'])+HOUR),
    'summary' => 'Reservation',
    'description' => $reservation['Reservation']['headcount'].' persons @ location foo',
    'organizer' => 'CEO',
    'class' => 'public',
    'timestamp' => '2010-10-08 22:23:34',
    'id' => 'reservation-'.$reservation['Reservation']['id'],
    'location' => $reservation['Restaurant']['address'],
);
$this->Ical->add($data);
echo $this->Ical->generate();

And all that is left again is to link this action accordingly:

echo $this->Html->link($this->Html->image('icons/calendar.gif', array('title'=>'Download reservation as ical-file')), array('action'=>'export', $reservation['Reservation']['id'], 'ext'=>'ical'), array('escape'=>false));

 
12 Comments

Posted in CakePHP