Create and publish a PHP package

A few weeks ago I had to write a few lines of PHP to interact with MailChimp and Twitter. To do so without writing an ugly index.php with an if-else-like structure, I ended up using Slim. It’s a micro Web framework for PHP that works like Sinatra. And to test my implementation, I used Postman, because it’s simpler than doing a curl or add some JS.

PHP has Composer to deal with packages management, so first I have to setup Composer on my project. It takes just one command: composer init. The whole thing is interactive, so at some point, I just have to give it which library I want to use. In this case, it’s slim/slim. Alternatively, it can be installed with: composer require slim/slim. Finally, I had my composer.json file and my dependencies installed as well. You can find more about the composer.json file in the documentation.

Then I just have to add some API wrappers for MailChimp and Twitter. After a diagonal reading of the Slim documentation and the corresponding wrappers API I had this pretty index.php file:

require 'vendor/autoload.php';

use \DrewM\MailChimp\MailChimp;
use \Abraham\TwitterOAuth\TwitterOAuth;

$app = new Slim\App();

$app->post('/mailchimp', function ($request, $response, $args) {
  $api_key          = 'my-api-key';
  $list_id          = 'a list id';
  $mailchimp_client = new MailChimp($api_key);
  $data             = $request->getParsedBody();

  $result = $mailchimp_client->post('lists/$list_id/members', [
    'email_address' => $data['email'],
    'status'        => 'subscribed'
  ]);

  return $response->withJson($result);
});

$app->get('/twitter', function ($request, $response, $args) {
  $twitter_client = new TwitterOAuth(
    'consumer_key', 'consumer_secret',
    'access_token', 'access_token_secret'
  );

  $tweets = $twitter_client->get('statuses/user_timeline', [
    'screen_name'     => 'user_name',
    'count'           => 5,
    'exclude_replies' => true,
    'include_rts'     => false
  ]);

  return $response->withJson($tweets);
});

$app->run();

As you can see, there are two routes for this little application. The first one is a POST on /mailchimp and the second one is a GET on /twitter. At this point, I have exactly what I want.

But according to the Twitter API documentation, there are some limitations to the Twitter API. The rate limit is pretty high, but it’s still a limit, and I don’t want to end with a 200 HTTP Status and an empty result from the API. To prevent this, I decided to implement a basic cache system that can work on a shared Web hosting service. Before writing anything, I had a look on packagist.org, but I found nothing as simple as I want. So I started writing my own package:

namespace Awea\TwitterFeed;

use Abraham\TwitterOAuth\TwitterOAuth;

class TwitterFeed
{
    // @var Assoc array used to merge with constructor $opts
    private $default_opts = [
        'cache_folder'     => 'api/cache',
        'cache_expiration' => 3600
    ];

    // @var String path to cache file
    private $cache_file;

    // @var Integer to define cache expiration
    private $cache_expiration;

    // @var String Twitter username
    private $screen_name;

    // @var Instance of TwitterOAuth
    private $twitter_client;

    /**
     * Constructor
     *
     * @param assoc array $opts with keys 'screen_name', 'consumer_key',
     * 'consumer_secret', 'access_token', 'access_token_secret',
     * 'cache_folder', 'cache_expiration'
     */
    public function __construct($opts)
    {
        $opts                   = $this->mergeDefaults($opts);
        $this->cache_expiration = $opts['cache_expiration'];
        $this->screen_name      = $opts['screen_name'];
        $this->twitter_client   = new TwitterOAuth(
            $opts['consumer_key'], $opts['consumer_secret'],
            $opts['access_token'], $opts['access_token_secret']
        );
        $this->cache_folder     = $this->getFullPath($opts['cache_folder']);
        $this->cache_file       = $this->cache_folder.$this->screen_name.'.json';
    }

    /**
     * Get User Timeline
     *
     * @param integer $count specifies the number of Tweets to try and retrieve
     * @param boolean $exclude_replies prevents replies from appearing
     * @param boolean $include_rts strips any native retweets
     *
     * @return array
     */
    public function getUserTL($count = 5, $exclude_replies = true, $include_rts = false) {
        // Check if cache file doesn't exist or needs to be updated
        if(!is_file($this->cache_file) || (date("U")-date("U", filemtime($this->cache_file))) > $this->cache_expiration) {
            $response = $this->twitter_client->get('statuses/user_timeline', [
                'screen_name'     => $this->screen_name,
                'count'           => $count,
                'exclude_replies' => $exclude_replies,
                'include_rts'     => $include_rts
            ]);

            $this->updateCache($response);
        }

        return $this->readCache();
    }

    /**
     * Merge constructor opts with default opts
     *
     * @param  array $opts Assoc array of arguments (see default_opts)
     * @return array merged with defaults
     */
    private function mergeDefaults($opts){
        return array_merge($this->default_opts, $opts);
    }

    /**
     * Read cache
     *
     * @return array
     */
    private function readCache() {
        return json_decode(file_get_contents($this->cache_file));
    }

    /**
     * Update Cache
     *
     * @return file_put_contents result (FALSE or number of bytes written)
     */
    private function updateCache($response) {
        if(is_dir($this->cache_folder)){
            if(is_writable($this->cache_folder)){
                return file_put_contents($this->cache_file, json_encode($response));
            }
            else {
                throw new TwitterFeedException("Error: The folder you have specified is not writable.");
            }
        }
        else {
            throw new TwitterFeedException("Error: The folder you have specified does not exist.");
        }
    }

    /**
     * Return full path to cache folder
     *
     * @param string $cache_folder relative location of the cache folder
     * @return string
     */
    private function getFullPath($cache_folder){
        $doc_root = $_SERVER['DOCUMENT_ROOT'];

        if (substr($doc_root, -1) != '/'){
          $doc_root = $doc_root.'/';
        }

        return $doc_root.$cache_folder . '/';
    }
}

This package comes with a specific composer.json to explain what it does, how it’s structured… It looks like this:

{
    "name": "awea/twitter_feed",
    "description": "Simple wrapper to get Twitter user timeline with cached results",
    "keywords": ["twitter", "feed"],
    "licence": "LGPL-3.0",
    "require": {
        "php": ">=5.3",
        "abraham/twitteroauth": "^0.7.2"
    },
    "authors": [
        {
            "name": "awea",
            "email": "aweaoftheworld@gmail.com"
        }
    ],
    "require-dev": {
    },
    "autoload": {
        "psr-4" : {
            "Awea\\TwitterFeed\\" : "src"
        }
    }
}

To test it before publication, I just linked this package to my application. To do so, I have to add a few lines in my composer.json:

"repositories": [
    {
        "type": "path",
        "url": "/home/awea/.workspace/twitter_feed"
    }
]

And I have to run the following command: composer require "awea/twitter_feed @dev". Then I have to update my index.php with my new homemade package:

use \Awea\TwitterFeed\TwitterFeed;

$app->get('/twitter', function ($request, $response, $args) {
  $twitter_cache = new TwitterFeed([
    'screen_name'         => 'screen_name',
    'consumer_key'        => 'consumer_key',
    'consumer_secret'     => 'consumer_secret',
    'access_token'        => 'access_token',
    'access_token_secret' => 'access_token_secret'
  ]);

  $tweets   = $twitter_cache->getUserTL();

  return $response->withJson($tweets);
});

Then I wanted to share this little package of mine on Packagist, so I submitted the package by adding the link to the repository on Github. Then I added the corresponding service on my Github repository, to maintain it without thinking about the update process on Packagist. This will pull automatically any change I made.

Now my TwitterFeed package is available through composer, and I can update it anytime by just creating a new tag name on git.

David Authier

David Authier
Développeur freelance