Adding Social Network Features to a PHP App with Neo4j

Christophe Willemsen
Share

In the last part, we learned about Neo4j and how to use it with PHP. In this post, we’ll be using that knowledge to build a real Silex-powered social network application with a graph database.

Bootstrapping the application

I’ll use Silex, Twig, Bootstrap and NeoClient to build the application.

Create a directory for the app. I named mine spsocial.

Add these lines to your composer.json and run composer install to install the dependencies :

{
  "require": {
    "silex/silex": "~1.1",
    "twig/twig": ">=1.8,<2.0-dev",
    "symfony/twig-bridge": "~2.3",
    "neoxygen/neoclient": "~2.1"

  },
  "autoload": {
    "psr-4": {
      "Ikwattro\\SocialNetwork\\": "src"
    }
  }
}

You can download and install Bootstrap to the web/assets folder of your project.

You can find the bootstrap demo app here as well: https://github.com/sitepoint-editors/social-network

Set up the Silex application

We need to configure Silex and declare Neo4jClient so it will be available in the Silex Application. Create an index.php file in the web/ folder of your project :

<?php

require_once __DIR__.'/../vendor/autoload.php';

use Neoxygen\NeoClient\ClientBuilder;

$app = new Silex\Application();

$app['neo'] = $app->share(function(){
    $client = ClientBuilder::create()
        ->addDefaultLocalConnection()
        ->setAutoFormatResponse(true)
        ->build();

    return $client;
});

$app->register(new Silex\Provider\TwigServiceProvider(), array(
    'twig.path' => __DIR__.'/../src/views',
));
$app->register(new Silex\Provider\MonologServiceProvider(), array(
    'monolog.logfile' => __DIR__.'/../logs/social.log'
));
$app->register(new Silex\Provider\UrlGeneratorServiceProvider());

$app->get('/', 'Ikwattro\\SocialNetwork\\Controller\\WebController::home')
    ->bind('home');

$app->run();

Twig is configured to have its template files located in the src/views folder.
A home route pointing to / is registered and configured to use the WebController we will create later.
The application structure should look like this :

Imgur

Note that here I used bower to install bootstrap, but it is up to you what you want to use.

The next step is to create our base layout with a content block that our child Twig templates will override with their own content.
I’ll take the default bootstrap theme with a navbar on top:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="">

    <title>My first Neo4j application</title>

    <!-- Bootstrap core CSS -->
    <link href="{{ app.request.basepath }}/assets/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!--[if lt IE 9]>
    <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
    <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->

    <style>
        body { padding-top: 70px; }
    </style>
</head>
<body>

<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
    <div class="container">
        <div class="navbar-header">
            <button type="button" id="collbut" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse">
                <span class="sr-only">Toggle navigation</span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="#">My first Neo4j application</a>
        </div>
    </div>
</div>

<div class="container-fluid">

    {% block content %}

    {% endblock content %}

</div>
</body>
</html>

The home page (retrieving all users)

So far, we have Neo4j available in the application, our base template is created and we want to list all users on the home page.

We can achieve this in two steps :

  • Create our home controller action and retrieve users from Neo4j
  • Pass the list of users to the template and list them

The Controller action

<?php

namespace Ikwattro\SocialNetwork\Controller;

use Silex\Application;
use Symfony\Component\HttpFoundation\Request;

class WebController
{

    public function home(Application $application, Request $request)
    {
        $neo = $application['neo'];
        $q = 'MATCH (user:User) RETURN user';
        $result = $neo->sendCypherQuery($q)->getResult();

        $users = $result->get('user');

        return $application['twig']->render('index.html.twig', array(
            'users' => $users
        ));
    }
}

The controller shows the process, we retrieve the neo service and issue a Cypher query to retrieve all the users.
The users collection is then passed to the index.html.twig template.

The index template

{% extends "layout.html.twig" %}

{% block content %}
    <ul class="list-unstyled">
        {% for user in users %}
            <li>{{ user.property('firstname') }} {{ user.property('lastname') }}</li>
        {% endfor %}
    </ul>
{% endblock %}

The template is very light, it extends our base layout and add an unsorted list with the user’s firstnames and lastnames in the content inherited block.

Start the built-in php server and admire your work :

cd spsocial/web
php -S localhost:8000
open localhost:8000

Imgur

Social Network Features: Showing whom a user follows

Let’s say now that we want to click on a user, and be presented his detail information and the users he follows.

Step 1 : Create a route in index.php

$app->get('/user/{login}', 'Ikwattro\\SocialNetwork\\Controller\\WebController::showUser')
    ->bind('show_user');

Step 2: Create the showUser controller action

public function showUser(Application $application, Request $request, $login)
    {
        $neo = $application['neo'];
        $q = 'MATCH (user:User) WHERE user.login = {login}
         OPTIONAL MATCH (user)-[:FOLLOWS]->(f)
         RETURN user, collect(f) as followed';
        $p = ['login' => $login];
        $result = $neo->sendCypherQuery($q, $p)->getResult();

        $user = $result->get('user');
        $followed = $result->get('followed');

        if (null === $user) {
            $application->abort(404, 'The user $login was not found');
        }

        return $application['twig']->render('show_user.html.twig', array(
            'user' => $user,
            'followed' => $followed
        ));
    }

The workflow is similar to any other applications, you try to find the user based on the login.
If it does not exist you show a 404 error page, otherwise you pass the user data to the template.

Step 3 : Create the show_user template file

{% extends "layout.html.twig" %}

{% block content %}
    <h1>User informations</h1>

    <h2>{{ user.property('firstname') }} {{ user.property('lastname') }}</h2>
    <h3>{{ user.property('login') }}</h3>
    <hr/>

    <div class="row">
        <div class="col-sm-6">
            <h4>User <span class="label label-info">{{ user.property('login') }}</span> follows :</h4>
            <ul class="list-unstyled">
                {% for follow in followed %}
                    <li>{{ follow.property('login') }} ( {{ follow.property('firstname') }} {{ follow.property('lastname') }} )</li>
                {% endfor %}
            </ul>
        </div>
    </div>

{% endblock %}

Step 4 : Refactor the list of users in the homepage to show links to their profile

{% for user in users %}
    <li>
        <a href="{{ path('show_user', { login: user.property('login') }) }}">
           {{ user.property('firstname') }} {{ user.property('lastname') }}
        </a>
    </li>
{% endfor %}

Refresh the homepage and click on any user for showing his profile and the list of followed users

Imgur

Adding suggestions

The next step is to provide suggestions to the profile. We need to slightly extend our cypher query in the controller by adding an OPTIONAL MATCH to find suggestions based on the second degree network.

The optional prefix causes a MATCH to return a row even if there were no matches but with the non-resolved parts set to null (much like an outer JOIN). As we potentially get multiple paths for each friend-of-a-friend (fof), we need to distinct the results in order to avoid duplicates in our list (collect is an aggregation operation that collects values into an array):

The updated controller :

public function showUser(Application $application, Request $request, $login)
    {
        $neo = $application['neo'];
        $q = 'MATCH (user:User) WHERE user.login = {login}
         OPTIONAL MATCH (user)-[:FOLLOWS]->(f)
         OPTIONAL MATCH (f)-[:FOLLOWS]->(fof)
         WHERE user <> fof
           AND NOT (user)-[:FOLLOWS]->(fof)
         RETURN user, collect(f) as followed, collect(distinct fof) as suggestions';
        $p = ['login' => $login];
        $result = $neo->sendCypherQuery($q, $p)->getResult();

        $user = $result->get('user');
        $followed = $result->get('followed');
        $suggestions = $result->get('suggestions');

        if (null === $user) {
            $application->abort(404, 'The user $login was not found');
        }

        return $application['twig']->render('show_user.html.twig', array(
            'user' => $user,
            'followed' => $followed,
            'suggestions' => $suggestions
        ));
    }

The updated template :

{% extends "layout.html.twig" %}

{% block content %}
    <h1>User informations</h1>

    <h2>{{ user.property('firstname') }} {{ user.property('lastname') }}</h2>
    <h3>{{ user.property('login') }}</h3>
    <hr/>

    <div class="row">
        <div class="col-sm-6">
            <h4>User <span class="label label-info">{{ user.property('login') }}</span> follows :</h4>
            <ul class="list-unstyled">
                {% for follow in followed %}
                    <li>{{ follow.property('login') }} ( {{ follow.property('firstname') }} {{ follow.property('lastname') }} )</li>
                {% endfor %}
            </ul>
        </div>

        <div class="col-sm-6">
            <h4>Suggestions for user <span class="label label-info">{{ user.property('login') }}</span> </h4>
            <ul class="list-unstyled">
                {% for suggested in suggestions %}
                    <li>{{ suggested.property('login') }} ( {{ suggested.property('firstname') }} {{ suggested.property('lastname') }} )</li>
                {% endfor %}
            </ul>
        </div>

    </div>

{% endblock %}

You can immediately explore the suggestions in your application :

Imgur

Connecting to a user (adding relationship)

In order to connect to a suggested user, we’ll add a post form link to each suggested user containing both users as hidden fields. We’ll also create the corresponding route and controller action.

Creating the route :

#web/index.php

$app->post('/relationship/create', 'Ikwattro\\SocialNetwork\\Controller\\WebController::createRelationship')
    ->bind('relationship_create');

The controller action :

public function createRelationship(Application $application, Request $request)
    {
        $neo = $application['neo'];
        $user = $request->get('user');
        $toFollow = $request->get('to_follow');

        $q = 'MATCH (user:User {login: {login}}), (target:User {login:{target}})
        MERGE (user)-[:FOLLOWS]->(target)';
        $p = ['login' => $user, 'target' => $toFollow];
        $neo->sendCypherQuery($q, $p);

        $redirectRoute = $application['url_generator']->generate('show_user', array('login' => $user));

        return $application->redirect($redirectRoute);
    }

Nothing unusual here, we MATCH for the start user node and the target user node and then we MERGE the corresponding FOLLOWS relationship. We use MERGE on the relationship to avoid duplicate entries.

The template:

<div class="col-sm-6">
            <h4>Suggestions for user <span class="label label-info">{{ user.property('login') }}</span> </h4>
            <ul class="list-unstyled">
                {% for suggested in suggestions %}
                    <li>
                        {{ suggested.property('login') }} ( {{ suggested.property('firstname') }} {{ suggested.property('lastname') }} )
                        <form method="POST" action="{{ path('relationship_create') }}">
                            <input type="hidden" name="user" value="{{ user.property('login') }}"/>
                            <input type="hidden" name="to_follow" value="{{ suggested.property('login') }}"/>
                            <button type="submit" class="btn btn-success btn-sm">Follow</button>
                        </form>
                        <hr/>
                    </li>
                {% endfor %}
            </ul>
        </div>

You can now click on the FOLLOW button of the suggested user you want to follow :

Imgur

Removing relationships :

The workflow for removing relationships is pretty much the same as for adding new relationships, create a route, a controller action and adapt the layout :

The route :

#web/index.php
$app->post('/relationship/remove', 'Ikwattro\\SocialNetwork\\Controller\\WebController::removeRelationship')
    ->bind('relationship_remove');

The controller action :

public function removeRelationship(Application $application, Request $request)
    {
        $neo = $application['neo'];
        $user = $request->get('login');
        $toRemove = $request->get('to_remove');

        $q = 'MATCH (user:User {login: {login}} ), (badfriend:User {login: {target}} )
        MATCH (user)-[follows:FOLLOWS]->(badfriend)
        DELETE follows';
        $p = ['login' => $user, 'target' => $toRemove];
        $neo->sendCypherQuery($q, $p);

        $redirectRoute = $application['url_generator']->generate('show_user', array('login' => $user));

        return $application->redirect($redirectRoute);
    }

You can see here that I used MATCH to find the relationship between the two users,
and I added an identifier follows to the relationship to be able to DELETE it.

The template :

<h4>User <span class="label label-info">{{ user.property('login') }}</span> follows :</h4>
            <ul class="list-unstyled">
                {% for follow in followed %}
                    <li>
                        {{ follow.property('login') }} ( {{ follow.property('firstname') }} {{ follow.property('lastname') }} )
                        <form method="POST" action="{{ path('relationship_remove') }}">
                            <input type="hidden" name="login" value="{{ user.property('login') }}"/>
                            <input type="hidden" name="to_remove" value="{{ follow.property('login') }}"/>
                            <button type="submit" class="btn btn-sm btn-warning">Remove relationship</button>
                        </form>
                        <hr/>
                    </li>
                {% endfor %}
            </ul>

You can now click the Remove relationship button under each followed user :

Imgur

Conclusion

Graph databases are the perfect fit for relational data, and using it with PHP and NeoClient is easy.
Cypher is a convenient query language you will quickly love, because it makes possible to query your graph in a natural way.

There is so much benefit from using Graph Databases for real world data,
I invite you to discover more by reading the manual http://neo4j.com/docs/stable/ ,
having a look at use cases and examples supplied by Neo4j users and following @Neo4j on Twitter.