diff --git a/composer.json b/composer.json
index 16e7eb8a6c01c5bec7fc5d48105365295aede85f..240bf04a84ba5033ce0c6f7d9ade446ceb24327c 100644
--- a/composer.json
+++ b/composer.json
@@ -10,11 +10,22 @@
         }
     ],
     "require": {
-        "php": ">=7.0.0"
+        "php": ">=7.0.0",
+        "guzzlehttp/guzzle": "^6.3"
+    },
+    "autoload": {
+        "psr-4": {
+            "Library\\": "src/library"
+        }
     },
     "require-dev": {
         "phpunit/phpunit": ">=4.8 < 6.0"
     },
+    "autoload-dev": {
+        "psr-4": {
+            "Tests\\": "tests/"
+        }
+    },
     "scripts": {
         "test": "phpunit"
     }
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c9a0bb995e837590f018973b78fa383506f4441c
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,10 @@
+<phpunit bootstrap="vendor/autoload.php">
+    <testsuites>
+        <testsuite name="php-modules">
+            <directory>tests</directory>
+        </testsuite>
+    </testsuites>
+    <php>
+        <env name="UNITTEST" value="yes"/>
+    </php>
+</phpunit>
diff --git a/src/library/Webquery.php b/src/library/Webquery.php
new file mode 100644
index 0000000000000000000000000000000000000000..8a49bc8a88a5561f4b489cf542c75dca54e0be0d
--- /dev/null
+++ b/src/library/Webquery.php
@@ -0,0 +1,321 @@
+<?php
+
+namespace Library;
+
+use GuzzleHttp\Psr7\Request;
+
+
+class Webquery {
+    use \Library\LoggerTrait;
+
+    /**
+     * @var array log context
+     */
+    protected $context = ['webquery'];
+    /**
+     * @var \GuzzleHttp\Client psr-7 http client
+     */
+    protected $http_client;
+    /**
+     * @var string webquery host name, default: 'library.wur.nl'
+     */
+    protected $host = 'library.wur.nl';
+    /**
+     * @var string webquery service name
+     */
+    protected $service = '';
+    /**
+     * @var string webquery suffix name, default: 'xml'
+     */
+    protected $suffix = 'xml';
+    /**
+     * @var GuzzleHttp\Psr7\Response
+     */
+    protected $response;
+    /**
+     * @var SimpleXMLElement parsed xml from response
+     */
+    protected $resp_xml;
+    /**
+     * @var array with children and attributes from error element
+     */
+    protected $resp_error;
+
+
+    public function __construct() {
+
+    }
+
+    /**
+     * setter for http_client
+     * @param \GuzzleHttp\Client $http_client
+     * @return $this
+     */
+    public function set_http_client(\GuzzleHttp\Client $http_client) {
+
+        $this->http_client = $http_client;
+        return $this;
+    }
+
+    /**
+     *  getter for http_client
+     *  if no http_client is set a default http_client is created
+     * @return \GuzzleHttp\Client
+     */
+    public function get_http_client() {
+
+        if (!isset($this->http_client)) {
+            $this->http_client = new \GuzzleHttp\Client();
+        }
+        return $this->http_client;
+    }
+
+    /**
+     * set host name for webquery url
+     * @param sting $host webquery host
+     * @return $this
+     */
+    public function set_host($host) {
+
+        $this->host = $host;
+        return $this;
+    }
+
+    /**
+     * set webquery service name for url
+     * @param string $service webquery service
+     * @return $this
+     */
+    public function set_service($service) {
+
+        $this->service = $service;
+        return $this;
+    }
+
+    /**
+     * set webquery suffix for url
+     * @param string $suffix webquery suffix
+     * @return $this
+     */
+    public function set_suffix($suffix) {
+
+        $this->suffix = $suffix;
+        return $this;
+    }
+
+    public function get_url() {
+
+        return "http://$this->host/WebQuery/$this->service/new_$this->suffix";
+    }
+
+/**
+ * convert xml tree to x-www-form-urlencoded string
+ * @param SimpleXMLElement $xml  xml tree
+ * @return string xml as x-www-form-urlencoded string
+ */
+    public function xml_to_wwwform($xml) {
+
+        $result = [];
+        foreach($xml as $key=>$value) {
+            $result[] = "$key=".($value->count() > 0 ? '&'.$this->xml_to_wwwform($value) : urlencode($value));
+        }
+        return implode('&', $result);
+    }
+
+    /**
+     * create new record in webquery
+     * uses properties host, service and suffix for the url
+     * @param SimpleXMLElement $xml post data (xmol tree)
+     * @return boolean true on success
+     */
+    public function create_record($xml) {
+
+        array_push($this->context, 'create_record');
+        $this->reset_response();
+
+        if (empty($this->service)) {
+            $this->error("create_record: service is not set", $this->context);
+            array_pop($this->context);
+            return false;
+        }
+
+        $url = $this->get_url();
+        $headers = ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8'];
+        $body = $this->xml_to_wwwform($xml);
+        $this->debug("request url: $url", $this->context);
+        $this->debug("request data: $body", $this->context);
+        $request = new Request('POST', $url, $headers, $body);
+        $options = [
+            'timeout' => 2,
+            'http_errors' => false, // disable exceptions on 4xx and 5xx responses
+        ];
+        try {
+            $this->response = $this->http_client->send($request, $options);
+        } catch (\GuzzleHttp\Exception\RequestException $ex) {
+            $this->error("exception posting to webquery: ".$ex->getMessage(), $this->context);
+            $this->resp_error = [
+                'status' => '500',
+                'code' => $ex->getCode(),
+                'message' => 'request exception: '.$ex->getMessage(),
+            ];
+            array_pop($this->context);
+            return false;
+        }
+
+        $success = $this->is_success();
+        $code = $this->get_error_code();
+        $message = $this->get_error_message();
+        if ($success) {
+            $this->info("success, code=$code, message=$message, isn=".$this->get_isn(), $this->context);
+        } else {
+            $this->error("failed, code=$code, message=$message", $this->context);
+        }
+        array_pop($this->context);
+        return $success;
+    }
+
+    /**
+     * retuns success status of last webquery action
+     * @return boolean true on success
+     */
+    public function is_success() {
+
+        // try to parse response xml
+        $this->resp_xml = $this->parse_xml($this->response->getBody());
+        if (empty($this->resp_xml)) {
+            $fake_resp = '<root><error status="500"><code>NO_XML_RESP</code><message>failed to parse xml response</message></error></root>';
+            $this->resp_xml = $this->parse_xml($fake_resp);
+            return false;
+        }
+
+        // get content of first error element
+        $error = $this->resp_xml->error[0];
+        if (isset($error)) {
+            foreach ($error->attributes() as $attr => $value) {
+                $this->resp_error[$attr] = (string) $value;
+            }
+            foreach ($error->children() as $key => $value) {
+                $this->resp_error[$key] = (string) $value;
+            }
+        }
+
+        if ($this->response->getStatusCode() != 200) {
+            return false;
+        }
+        if (!isset($this->resp_error['status']) || $this->resp_error['status'] != 200) {
+            return false;
+        }
+
+         return true;
+    }
+
+    /**
+     *  return response object of last webquery action
+     * @return psr-7 response object
+     */
+    public function get_response() {
+
+        return $this->response;
+    }
+
+    /**
+     * retuns content of first webquery error element of last webquery action
+     * @return array with all webquery error attributes and elements
+     */
+    public function get_error() {
+
+        return $this->resp_error;
+    }
+
+    /**
+     * return value of given error property of last webquery action
+     * @param string $name webquery error property name (attribute or ellement
+     * @return string value or empty string if not set
+     */
+    public function get_error_value($name) {
+
+        return isset($this->resp_error[$name]) ? $this->resp_error[$name] : '';
+    }
+
+    /**
+     * return value of webquery error code of last webquery action
+     * @return string value of code error property or empty string
+     */
+    public function get_error_code() {
+
+        return $this->get_error_value('code');
+    }
+
+    /**
+     * return value of webquery error status of last webquery action
+     * @return string value of status error property or empty string
+     */
+    public function get_error_status() {
+
+        return $this->get_error_value('status');
+    }
+
+    /**
+     * return value of webquery error message of last webquery action
+     * the return message is a composite of the message, field and xmlerror properties
+     * @return string value of message error property or empty string
+     */
+    public function get_error_message() {
+
+        $message = $this->get_error_value('message');
+        $field = $this->get_error_value('field');
+        if (!empty($field)) {
+            $message = "$message: $field";
+        }
+        $xmlerror = $this->get_error_value('xmlerror');
+        return empty($xmlerror) ? $message : "$message ($xmlerror)";
+    }
+
+    /**
+     * return value of webquery error isn of last webquery action
+     * isn is set even if webquery failed to create a record
+     * @return string value of isn error property or empty string
+     */
+    public function get_isn() {
+
+        return $this->get_error_value('isn');
+    }
+
+
+    protected function reset_response() {
+
+        $this->response = null;
+        $this->resp_xml = null;
+        $this->resp_error = [];
+    }
+
+    /**
+     * parse xml content from message
+     *
+     * @param $text
+     * @return SimpleXMLElement parsed xml or false on errors
+     */
+    public function parse_xml($text) {
+
+        if (trim($text) == '') {
+            $this->error("xml string is empty", $this->context);
+            return false;
+        }
+
+        libxml_use_internal_errors(true);
+        $xml = simplexml_load_string($text);
+        $errors = libxml_get_errors();
+        libxml_clear_errors();
+        $error_count = count($errors);
+        if ($error_count > 0) {
+            $this->error("parsing xml string failed with $error_count errors (max. 10 listed", $this->context);
+            // only log first 10 error messages
+            foreach (array_slice($errors, 0, 9) as $error) {
+                $this->error('xml parse error: ' . print_r($error, true), $this->context);
+            }
+            return false;
+        }
+        return $xml;
+    }
+
+}
diff --git a/tests/library/WebqueryTest.php b/tests/library/WebqueryTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b46759d381626b4eb742c3220eefbbefd855bd3b
--- /dev/null
+++ b/tests/library/WebqueryTest.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Tests\Functional\Library;
+
+use GuzzleHttp\Psr7\Response;
+
+class WebqueryTest extends \PHPUnit_Framework_TestCase {
+
+
+    public function test_get_url() {
+
+        // with default settings
+        $wq = new \Library\Webquery();
+        $expect = 'http://library.wur.nl/WebQuery//new_xml';
+        $this->assertEquals($expect, $wq->get_url(), 'no properties set, default url');
+
+        // with url properties set
+        $wq = new \Library\Webquery();
+        $wq->set_host('devel.library.wur.nl')
+            ->set_service('testservice')
+             ->set_suffix('record');
+        $expect = 'http://devel.library.wur.nl/WebQuery/testservice/new_record';
+        $this->assertEquals($expect, $wq->get_url(), 'no properties set, default url');
+    }
+
+
+    public function test_xml_to_wwwform() {
+
+        $xml = get_test_xml();
+        $wq = new \Library\Webquery();
+        $wwwform = $wq->xml_to_wwwform($xml);
+        $expect = 'sub1=subval1&sub2=&child1=childval1&child2=childval2&sub3=subval+3';
+        $this->assertEquals($expect, $wwwform, 'record in x-www-form-urlencoded string');
+    }
+
+    public function test_create_record() {
+
+        // create mock http client that stores requests and responses
+        $request_history = [];
+        $responses = [
+            new Response(200, [], '<recordset><error status="200" isn="13"><code>WQW_OK</code><message>record created</message></error></recordset>'),
+            new Response(500, [], '<recordset><error status="501"><code>WQE_FAILED</code><message>record not created</message></error></recordset>'),
+        ];
+        $mock_httpclient = get_mock_httpclient($responses, $request_history);
+
+        $wq = new \Library\Webquery();
+        $wq->set_http_client($mock_httpclient);
+        //$wq->set_logger(new \Monolog\Logger('create_record'));
+
+        $wq->set_host('devel.library.wur.nl')
+            ->set_service('testservice');
+
+        $xml = get_test_xml();
+
+        // first request succeeds
+        $result = $wq->create_record($xml);
+        $this->assertTrue($result, 'first request return value');
+        $this->assertEquals('200', $wq->get_error_status(), 'first response error status');
+        $this->assertEquals('WQW_OK', $wq->get_error_code(), 'first response error code');
+        $this->assertEquals('record created', $wq->get_error_message(), 'first response error message');
+        $this->assertEquals('13', $wq->get_isn(), 'first response isn');
+
+        // second request fails
+        $wq->set_suffix('record');
+        $result = $wq->create_record($xml);
+        $this->assertFalse($result, 'second request return value');
+        $this->assertEquals('501', $wq->get_error_status(), 'second response error status');
+        $this->assertEquals('WQE_FAILED', $wq->get_error_code(), 'second response error code');
+        $this->assertEquals('record not created', $wq->get_error_message(), 'second response error message');
+        $this->assertEquals('', $wq->get_isn(), 'second response isn');
+
+        $this->assertEquals(2, count($request_history), 'request history count');
+        // check first request
+        $request = $request_history[0]['request'];
+        $this->assertEquals('POST', $request->getMethod(), 'request 1 method');
+        $expect = 'http://devel.library.wur.nl/WebQuery/testservice/new_xml';
+        $this->assertEquals($expect, $request->getUri(), 'request 1 url');
+        $expect = 'sub1=subval1&sub2=&child1=childval1&child2=childval2&sub3=subval+3';
+        $this->assertEquals($expect, $request->getBody(), 'request 1 body');
+        // check second request
+        $request = $request_history[1]['request'];
+        $this->assertEquals('POST', $request->getMethod(), 'request 2 method');
+        $expect = 'http://devel.library.wur.nl/WebQuery/testservice/new_record';
+        $this->assertEquals($expect, $request->getUri(), 'request 2 url');
+        $expect = 'sub1=subval1&sub2=&child1=childval1&child2=childval2&sub3=subval+3';
+        $this->assertEquals($expect, $request->getBody(), 'request 2 body');
+
+    }
+
+
+}
+
+/***********  Helper Functions    **************/
+
+function get_test_xml() {
+
+    $xml_txt = <<<EOT
+<record>
+    <sub1>subval1</sub1>
+    <sub2>
+        <child1>childval1</child1>
+        <child2>childval2</child2>
+    </sub2>
+    <sub3>subval 3</sub3>
+</record>
+EOT;
+    $wq = new \Library\Webquery();
+    return $wq->parse_xml($xml_txt);
+}
+
+
+function get_mock_httpclient(array $mock_responses, array &$history = null) {
+
+    foreach ($mock_responses as $key => $value) {
+        if (is_int($value)) {
+            $mock_responses[$key] = new \GuzzleHttp\Psr7\Response($value);
+        }
+    }
+    $mock_handler = new \GuzzleHttp\Handler\MockHandler($mock_responses);
+    $mock_stack = \GuzzleHttp\HandlerStack::create($mock_handler);
+    if (isset($history)) {
+        $mock_history = \GuzzleHttp\Middleware::history($history);
+        $mock_stack->push($mock_history);
+    }
+    return new \GuzzleHttp\Client(['handler' => $mock_stack]);
+}