jeremykendall.net

Trending on GitHub

Tweet could not be processed

Thanks to yesterday’s link love from PHPDeveloper, I’ve made both the Trending PHP Developer list and the Trending PHP Repo list on GitHub.

With internet fame being so fleeting, I took some screenshots for posterity. Now please excuse me while I get some ice for my arm. I seem to have injured myself while patting myself on the back.

API Query Authentication With Query Auth

Most APIs require some sort of query authentication: a method of signing API requests with an API key and signature. The signature is usually generated using a shared secret. When you’re consuming an API, there are (hopefully) easy to follow steps to create signatures. When you’re writing your own API, you have to whip up both server-side signature validation and a client-side signature creation strategy. Query Auth endeavors to handle both of those tasks; signature creation and signature validation.

Philosophy

Query Auth is intended to be — and is written as — a bare bones library. Many of niceties and abstractions you’d find in a fully featured API library or SDK are absent. The point of the library is to provide you with the ability to focus on writing the meat of your API while offloading the authentication bits.

What’s Included?

There are three components to Query Auth: request signing for API consumers and creators, request signature validation for API creators, and API key and API secret generation.

Request Signing

1
2
3
4
5
6
7
8
9
10
11
12
$collection = new QueryAuth\NormalizedParameterCollection();
$signer = new QueryAuth\Signer($collection);
$client = new QueryAuth\Client($signer);

$key = 'API_KEY';
$secret = 'API_SECRET';
$method = 'GET';
$host = 'api.example.com';
$path = '/resources';
$params = array('type' => 'vehicles');

$signedParameters = $client->getSignedRequestParams($key, $secret, $method, $host, $path, $params);

Client::getSignedRequestParams() returns an array of parameters to send via the querystring (for GET requests) or the request body. The parameters are those provided to the method (if any), plus timestamp, key, and signature.

Signature Validation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$collection = new QueryAuth\NormalizedParameterCollection();
$signer = new QueryAuth\Signer($collection);
$server = new QueryAuth\Server($signer);

$secret = 'API_SECRET_FROM_PERSISTENCE_LAYER';
$method = 'GET';
$host = 'api.example.com';
$path = '/resources';
// querystring params or request body as an array,
// which includes timestamp, key, and signature params from the client's
// getSignedRequestParams method
$params = 'PARAMS_FROM_REQUEST';

$isValid = $server->validateSignature($secret, $method, $host, $path, $params);

Server::validateSignature() will return either true or false. It might also throw one of three exceptions:

  • MaximumDriftExceededException: If timestamp is too far in the future
  • MinimumDriftExceededException: It timestamp is too far in the past
  • SignatureMissingException: If signature is missing from request params

Drift defaults to 15 seconds, meaning there is a 30 second window during which the request is valid. The default value can be modified using Server::setDrift().

Key Generation

You can generate API keys and secrets in the following manner.

1
2
3
4
5
6
7
8
9
$randomFactory = new \RandomLib\Factory();
$keyGenerator = new QueryAuth\KeyGenerator($randomFactory);

// 40 character random alphanumeric string
$key = $keyGenerator->generateKey();

// 60 character random string containing the characters
// 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ./
$secret = $keyGenerator->generateSecret();

Both key and secret are generated using Anthony Ferrara’s RandomLib random string generator.

That’s Kinda Ugly, Dude

As I pointed out, the Query Auth library is pretty bare bones. There are a lot of opportunities for abstraction that would make the library much easier to use and much nicer to look at. If I added them to Query Auth, however, that would lock library users into whichever HTTP client I chose to use. The same concern would go for whatever other abstractions I decided on. The point here is to offload query authentication, and only query authentication, to the Query Auth library.

Sample Implementation

In order to demonstrate how one might implement the Query Auth library, I’ve whipped up a sample implementation for you.

The sample uses Vagrant and VirtualBox to allow you to see the whole thing in action. Slim Framework runs the API, Guzzle is used to make requests to the API, and both a GET and POST request are implemented. JSend, Jamie Schembri’s PHP implementation of the OmniTI JSend specifiction, is used to send messages back from the API, and Parsedown PHP, Emanuil Rusev’s Markdown parser for PHP, is used to render the sample implementation’s documentation.

Request Signing

In the sample implementation, request signing has been abstracted in the Example\ApiRequestSigner class. Signing requests is now as simple as passing the request object and credentials object to the signRequest method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
 * Signs API request
 *
 * @param RequestInterface $request     HTTP Request
 * @param ApiCredentials   $credentials API Credentials
 */
public function signRequest(RequestInterface $request, ApiCredentials $credentials)
{
    $signedParams = $this->client->getSignedRequestParams(
            $credentials->getKey(),
            $credentials->getSecret(),
            $request->getMethod(),
            $request->getHost(),
            $request->getPath(),
            $this->getParams($request)
            );

    $this->replaceParams($request, $signedParams);
}

Signature Validation

In the sample implementation, signature validation has been abstracted in the Example\ApiRequestValidator class. Validating request signatures is now as simple as passing the request object and credentials object to the isValid method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * Validates an API request
 *
 * @param  Request        $request     HTTP Request
 * @param  ApiCredentials $credentials API Credentials
 * @return bool           True if valid, false if invalid
 */
public function isValid(Request $request, ApiCredentials $credentials)
{
    return $this->server->validateSignature(
        $credentials->getSecret(),
        $request->getMethod(),
        $request->getHost(),
        $request->getPath(),
        $this->getParams($request)
    );
}

Signing a GET Request

Signing a request is now extremely clean and simple. Here’s the GET example from the sample implementation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
 * Sends a signed GET request which returns a famous mangled phrase
 */
$app->get('/get-example', function() use ($app, $credentials, $requestSigner) {

    // Create request
    $guzzle = new GuzzleClient('http://query-auth.dev');
    $request = $guzzle->get('/api/get-example');

    // Sign request
    $requestSigner->signRequest($request, $credentials);

    $response = $request->send();

    $app->render('get.html', array('request' => (string) $request, 'response' => (string) $response));
});

Validating a GET Request

Validating a GET request is equally clean and simple. Note the try/catch that handles possible exceptions from the validation class.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * Validates a signed GET request and, if the request is valid, returns a
 * famous mangled phrase
 */
$app->get('/api/get-example', function () use ($app, $credentials, $requestValidator) {

    try {
        // Validate the request signature
        $isValid = $requestValidator->isValid($app->request(), $credentials);

        if ($isValid) {
            $mistakes = array('necktie', 'neckturn', 'nickle', 'noodle');
            $format = 'Klaatu... barada... n... %s!';
            $data = array('message' => sprintf($format, $mistakes[array_rand($mistakes)]));
            $jsend = new JSendResponse('success', $data);
        } else {
            $jsend = new JSendResponse('fail', array('message' => 'Invalid signature'));
        }
    } catch (\Exception $e) {
        $jsend = new JSendResponse('error', array(), $e->getMessage());
    }

    $response = $app->response();
    $response['Content-Type'] = 'application/json';
    echo $jsend->encode();
});

Sample Request and Response

The code above produces the below request and response:

Request

1
2
3
GET /api/get-example?key=ah5yEgQzjuFsC9nWsRI4Nar3ikOqWVPcD3OntHpg&timestamp=1376416267&signature=3DqimkvigYBorGi8wHfil9lB8oCWhB%2BHYt6rVfE4zx4%3D HTTP/1.1
Host: query-auth.dev
User-Agent: Guzzle/3.7.2 curl/7.22.0 PHP/5.5.1-2+debphp.org~precise+2

Response

1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
Date: Tue, 13 Aug 2013 17:51:07 GMT
Server: Apache/2.4.6 (Ubuntu)
X-Powered-By: PHP/5.5.1-2+debphp.org~precise+2
Content-Length: 75
Content-Type: application/json

{"status":"success","data":{"message":"Klaatu... barada... n... necktie!"}}

Wrapping Up

So there you have it: QueryAuth to sign and validate API requests (and generate keys and secrets!) and a sample implementation to get you going. If you find this helpful, or have any questions or comments, please let me know. If you find any horrible mistakes, please feel free to submit an issue or a pull request, or you can always submit the offending code to CSI: PHP :–)

Vagrant Synced Folders Permissions

UPDATE: Since writing this post Vagrant has changed the synced folder settings and I’ve come across a new (and better?) way of handling this problem. Scroll down for the updates.

Having trouble getting your Synced Folders permissions just right in your Vagrant + VirtualBox VM? They’ve been giving me some grief lately. Here are the (undocumented) Vagrantfile options that finally got it sorted out.

Permissions Challenges

The issue that got me digging into this was trying to get permissions just so to allow my web apps to write logs. As you probably know, apache (or your web server of choice), sometimes needs write access to certain web application directories. I’ve always take care of that by adding the apache user group to the directories in question and then giving that user full access. Doing that in Ubuntu looks something like:

1
2
chown -R jeremykendall.www-data /path/to/logs
chmod 775 /path/to/logs

If you’ve tried doing something similar in your Vagrant shared folders, you’ve likely failed. This, as it turns out, doesn’t work with VirtualBox shared folders — you have to make the changes in your Vagrantfile.

Setting Permissions via the Vagrantfile

UPDATE: Thanks to Joe Ferguson for pointing out in the comments that Vagrant has been upgraded and my example was no longer current. Below are both examples marked by Vagrant version.

Here’s my new synced_folder setting in my Vagrantfile:

Vagrant v1.1+:

1
2
3
4
5
  # Vagrant v1.1+
  config.vm.synced_folder "./", "/var/sites/dev.query-auth", id: "vagrant-root",
    owner: "vagrant",
    group: "www-data",
    mount_options: ["dmode=775,fmode=664"]

Vagrant 1.0.x:

1
2
3
4
5
  # Vagrant v1.0.x
  config.vm.synced_folder "./", "/var/sites/dev.query-auth", id: "vagrant-root",
    :owner => "vagrant",
    :group => "www-data",
    :extra => "dmode=775,fmode=664"

I’m sure you can immediately see what resolved the issue. Lines 3 and 4 set the owner and group, respectively, and line 5 sets directory and file modes appropriately. That simple fix was frustratingly difficult because I couldn’t find it documented anywhere. After much searching and opening far too many browser tabs, I cobbled together the info above. A quick vagrant reload later and I was off to the races.

UPDATE: Alternate Method

An alternate method that doesn’t include modifying your synced folder permissions is changing the web user to the vagrant user. Bad idea? Security problem? Not on your dev VM it ain’t, and that’s good enough for me. Big thanks to Chris Tankersley for all the help getting this one figured out.

Tweet could not be processed

Chris and I both put together gists, and this is how I’m currently doing it in Flaming Archer, but probably the best method for changing the apache user to the vagrant user comes from the Intracto Puppet apache manifest.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Source https://raw.github.com/Intracto/Puppet/master/apache2/manifests/init.pp

# Change user
exec { "ApacheUserChange" :
    command => "sed -i 's/APACHE_RUN_USER=www-data/APACHE_RUN_USER=vagrant/' /etc/apache2/envvars",
    onlyif  => "grep -c 'APACHE_RUN_USER=www-data' /etc/apache2/envvars",
    require => Package["apache2"],
    notify  => Service["apache2"],
}

# Change group
exec { "ApacheGroupChange" :
    command => "sed -i 's/APACHE_RUN_GROUP=www-data/APACHE_RUN_GROUP=vagrant/' /etc/apache2/envvars",
    onlyif  => "grep -c 'APACHE_RUN_GROUP=www-data' /etc/apache2/envvars",
    require => Package["apache2"],
    notify  => Service["apache2"],
}

Additionally, if you’re copying and pasting from anywhere, don’t forget to change the apache lockfile permissions:

1
2
3
4
5
6
7
# Source https://github.com/Intracto/Puppet/blob/master/apache2/manifests/init.pp

exec { "apache_lockfile_permissions" :
    command => "chown -R vagrant:www-data /var/lock/apache2",
    require => Package["apache2"],
    notify  => Service["apache2"],
}

ACL on Shared Folders That Are Not NFS

One of the reasons the above methods are necessary is that you can’t use ACLs on shared directories. If none of the above options appeal to you, it’s possible to use ACLs on your VM as long as the directories aren’t shared. For more information, see Frank Stelzer’s comment regarding setfacl on a Vagrant box.

I Love You Guys

Tuesday was a frustrating day at work, one I spent chasing a bug that I was never able to find. My friends on Twitter made it all better.

Tweet could not be processed
Tweet could not be processed
Tweet could not be processed
Tweet could not be processed
Tweet could not be processed
Tweet could not be processed
Tweet could not be processed

Number of the Contributor

My contributions at work, Aug 05 2012 – Aug 05 2013.

Woe to you, o earth and sea, for the devil sends the beast with wrath, because he knows the time is short … Let him who hath understanding reckon the number of the beast: for it is a human number; its number is six hundred and sixty six.

The Composer Kerfuffle

Wednesday night I discovered, much to my shock and dismay, that Composer’s install command now defaults to installing development dependencies along with a project’s required dependencies. That discovery prompted me to start a “small twitter shitstorm” with this tweet:

Tweet could not be processed

Before I delve into why I’m shocked and dismayed, I want to say a few things about Composer and the Composer team. In all of my years of programming in PHP, I’m not sure there’s been a more important, more game changing, or more exciting project than Composer. Being able to easily manage project dependencies has revolutionized the way I develop. Composer, and the related packagist.org, have been a larger quality-of-life improvement for me than any other tool I’ve added to my toolkit over the years. I’d like to extend my sincerest thanks to Nils Adermann, Jordi Boggiano and the many community contributors who have worked so hard and so diligently to make Composer a reality.

My Beef with the Change

1. There was never any public discussion about the change

Beyond a few brief asides in a couple of github issues and pull requests, I can’t find anywhere this change discussed publicly. For a project of this size and this importance, removing the community from the decision making process was a terrible mistake. I’d much prefer to have argued all of this before the change than after.

Yesterday, Jordi posted “Composer: Installing require-dev by default” to explain his rationale for the change. One of his points is that he made a note in the 1.0.0-alpha changelog regarding the upcoming change. While this is true, I find it insufficient. I rarely read changelogs (my bad), but I certainly don’t read changelogs to discover what’s coming in the future. That’s what road maps, blog posts, and PRs are for. Putting that note in the changelog was “too little, too early”.

2. Composer philosophy and workflow has always been, “install for production, update for development”

The longstanding Composer rule of thumb has always been “install for production, update for development”. Adam Brett’s post on Composer workflow and the difference between update and install is a good example of this rule of thumb. Humorously enough, Jordi reinforces that rule of thumb (while defending the change that composer update installs dev requirements by default) in his blog post immediately prior to the post defending the composer install change:

“The install command on the hand remains the same. It does not install dev dependencies by default, and it will actually remove them if they were previously installed and you run it without —dev. Again this makes sense since in production you should only run install to get the last verified state (stored in composer.lock) of your dependencies installed.”

That rule of thumb is now turned on its head, and the default “composer install in production” advice now needs updating, careful warnings, and caveats.

3. Tools should never default to dev (unless they’re meant for dev, of course)

This is a philosophical point on my part, but it’s one I don’t think is unique to me and it’s one that I think can be well defended. My point here is that one should always write code and tools in such a way that deployments to production will only ever result in production code being deployed. Clear as mud? Let me try with an example.

I frequently use environment variables to allow my applications to detect which environment they’re running in. If those environment variables don’t exist, then the application should default to production. Why? Because dev environments are the special case, not production, and it’s far too easy to forget to add those environment variables when deploying. I make my life easier by making the production environment as idiot-proof as possible, and not the other way around.

This philosophy was in place in the prior behavior of composer install (and composer update, for that matter). Now that it’s changed, the production environment is far more likely to suffer than the development environment. Forgetting to add the —dev flag in development is a lot less (potentially) costly than forgetting to add the —no-dev flag in production.

In a seeming contradiction, I’ve said that I have no problem with composer update defaulting to installing dev dependencies. I’ve gone back and forth on that a bit when considering my “tools should never default to dev” position, but I don’t think I’m being inconsistent here. Since the rule of thumb encourages using update in development and never in production, then update becomes a dev tool which can safely default to installing development dependencies. Having said that, if consistency between commands is important, then composer update should no longer default to dev and the changes to both install and update should be reverted.

In Closing

Composer has become an integral part of my workflow, and a critical piece of the PHP development process in general. I loved Composer before this change and I’ll love Composer after. That said, changing the Composer command that is intended primarily for production use is extremely disruptive and a very bad call, especially considering how the change came about.

Piedmont Natural Gas: Customer Service WIN

(This is the follow-up to yesterday’s post, “Dear Piedmont Natural Gas”. Start there for the full story.)

As I write this follow-up post, the gas has been turned back on, my hot water heater is working away, and my wife and I find ourselves on the far side of a bad situation turned good.

We last left off with a phone call from Piedmont corporate and a promise to have our gas turned back on by 5 pm CST. Not only did Piedmont come through, they came through big. The technician they sent was early to the appointment and went above-and-beyond to make sure we were taken care of. While I wish this entire situation never happened, the outcome is the very best of a bad situation, and I want to close this out with a big thanks to Piedmont Natural Gas.

Once Piedmont corporate learned about our problem, they went the extra mile to resolve it to our satisfaction as quickly as possible. I don’t know everything they did, but they went as far as reviewing the recordings of both our Friday and Saturday customer service phone calls, calling both my wife and myself to schedule reconnection, and taking the time to explain exactly what happened, why it happened, and then take responsibility for it. Kudos to Piedmont.

Thanks also to all of you for being so supportive. In the grand scheme of things, this was small potatoes, but that didn’t make it any less upsetting. The retweets, supportive comments, and personal commiserations went a long way towards making this a lot easier.

Dear Piedmont Natural Gas: Updated

My wife and I recently moved to a suburb of Nashville, TN. Through no fault of our own, we had our natural gas service shut off yesterday and the provider, Piedmont Natural Gas, is refusing to restore our service until Tuesday, December 4. Here is my letter of complaint to Piedmont requesting that they make this right.

My wife and I moved to Hermitage, TN in late October 2012. She called to have natural gas service transferred into our name the week of October 22. Yesterday, November 30, our landlord got in touch with us to let us know the previous tenants had received a bill and we needed to touch base with you and take care of the problem. Which we did.

  • Here’s the payment confirmation number: 5752712
  • Here’s the line item from my checking account’s online system: 11/30/2012 Sign Debit* PIEDMONTNG/SPEEDPAY $70.15

But guess what? Our gas got cut off.

And guess what else? You won’t turn it back on until Tuesday, December 4th.

And guess what else? The representative my wife talked to yesterday afternoon told us the gas would not be cut off, that there wasn’t an appointment to have it cut off, and that everything was taken care of.

The best part? The gas was already cut off. Before we called. Before we were able to make payment. Through no fault of our own.

This is absolutely, completely and totally, 100% the fault of your company and your representatives at Piedmont Natural Gas.

It is wholly unacceptable that, after explaining our situation to your representative on the telephone this morning, you refuse to turn our gas back on.

You and I both know you have the ability to send a truck out and have our gas turned back on. You and I both know that you’ve done it for other customers in the past, and that you’ll do it for other customers in the future. I respectfully request that you do it for my wife and I now.

This one is on you. We did our part. Now do yours and make this right today.

Best,

Jeremy Kendall

Piedmont, the ball is in your court.

(I informed Piedmont that I would be making this complaint public here on my blog, and will update this post with both sides of the story as the situation unfolds.)

Update: Poor customer service with a positive result

Yesterday

By yesterday at 10 am CST, I had spoken to Piedmont customer service, sent them an email, written this blog post, and pinged them on both their Twitter account and their Facebook page. I kept an eye on my phone, email, and social networks all day, finally giving up around 5 pm. Finally, at 6:41 pm CST, almost three hours after Piedmont customer service closed for the weekend, I received this reply via Twitter:

Tweet could not be processed

Piedmont had six hours to touch base with us and resolve the problem. I pinged them in every possible way, making what I felt was a reasonable request for restoration of services. Rather than jumping on the problem and restoring our service, we were contacted after hours and told that customer service has been “made aware”. Thanks, but I’d already covered that one, and they won’t be open until Monday morning anyhow. Not impressed.

This morning

I didn’t sleep well last night, so I went down for a quick nap this morning. When I woke up, I had a missed call and voicemail from Charlotte, NC. The message was from Piedmont Natural Gas asking for a return call. As it turns out, they called Megan as well. After a day-and-a-half without service, Piedmont has promised to roll a truck and have our service turned back on.

I appreciate the extra effort they’ve taken to reach out to us on a Sunday, but I don’t appreciate having to fight for it. We’ll see if we get a truck this evening or not. I’ll let you know how it goes.

Update: Victory!

Our gas is back on and we’re happy with how this thing turned out. Please see my follow-up post, “Piedmont Natural Gas: Customer service WIN”, for the details.

Goofing Off With Unit Tests

[UPDATE: The library and test have been refactored. See the commit for full details.]

Sometime last year I saw a unit test for the song “Ice Cream Paint Job”.  I thought it was hilarious, and I hate I’ve never been able to find it again.  I liked it so much, in fact, I decided to write my own musical unit integration test.  Behold the MelissaTest, a short, simple test covering the main premise of “Melissa”. Bonus points for me: the test passes.  Enjoy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php

namespace MercyfulFate\Album\Melissa\Track;

use MercyfulFate\KingDiamond;
use MercyfulFate\Priest;
use MercyfulFate\Witch\Melissa as WitchMelissa;
use MercyfulFate\Album\Melissa\Track\Melissa as TrackMelissa;

class MelissaTest extends \PHPUnit_Framework_TestCase
{

    /**
     * @var MercyfulFate\KingDiamond
     */
    protected $king;

    /**
     * @var MercyfulFate\Priest
     */
    protected $priest;

    /**
     * @var MercyfulFate\Witch\Melissa
     */
    protected $witch;

    /**
     * @var MercyfulFate\Album\Melissa\Track\Melissa
     */
    protected $trackMelissa;

    protected function setUp()
    {
        $this->king = new KingDiamond();
        $this->priest = new Priest();
        $this->priest->attach($this->king);
        $this->witch = new WitchMelissa();
        $this->trackMelissa = new TrackMelissa($this->king, $this->witch, $this->priest);
    }

    protected function tearDown()
    {
        $this->trackMelissa = null;
    }

    public function testBurnMelissa()
    {
        $this->assertFalse($this->witch->isBurned());
        $this->assertFalse($this->king->swearsRevenge());

        $this->trackMelissa->priestBurnsWitch();

        $this->assertTrue($this->witch->isBurned());
        $this->assertTrue($this->king->swearsRevenge());
    }

}

Memphis PHP #3 - Presentation Slides and Example Code Included

We had a great time last night at Memphis PHP.  The turnout was great, it was fun meeting new folks and seeing old friends, and the conversations before and after the meeting were worth the drive to East Memphis.

I had a blast presenting “A Brief Introduction to Zend_Form.”  I love presenting, especially when the audience gets into the act, asking tons of good questions and laughing at my horrible jokes.  Thanks to all who showed up and for making Memphis PHP and last night’s event such a great success.

Slides and Example Code

As promised, I’ve posted “A Brief Introduction to Zend_Form” at SlideShare.  Included below are links to the example code on pastebin.

The best way to run the MVC example is to whip up a Zend Framework project and drop Example.php into /application/forms.  If you’re unfamiliar with how to get a Zend Framework project up and running, please see “From Zero to Zend Framework Project in 10 Minutes.”

Thanks to our Sponsors

Many thanks to Dave Barger of LunaWeb for kindly allowing us to use the LunaWeb offices for our meeting place, providing food, soda, and even beer.  Yup.  Free beer.  Not bad for our third event, eh?

Dave shared some information last night about his company, LunaWeb, some upcoming events, and some of their current and upcoming projects.  Here’s a list of the links that he referred to.

For your radars:

Big thanks to G2 Technology for helping us spread the word about Memphis PHP.  They work hard every month to promote our events, and I’m grateful to them for all they do.

Join Us

If you’re local to the Memphis area, visiting Memphis, or close enough to drive in, and you’re not a member of Memphis PHP, we’d love to have you join us.  Head over to MemphisPHP.org and join the group.  You’ll get announcements from the group, info about meetings, and be able to RSVP for events.  I look forward to meeting you at our next event.